diff --git a/.documentation/SchemaUpdates.md b/.documentation/SchemaUpdates.md index 0cbeec3d..0a5533e7 100644 --- a/.documentation/SchemaUpdates.md +++ b/.documentation/SchemaUpdates.md @@ -17,4 +17,6 @@ on the python side: And then run the build (or test commands if available) for each from @duc/package.json -and in case you need to check the fbs schema or what changed (changes may be git staged): @duc/schema/duc.sql \ No newline at end of file +and in case you need to check the fbs schema or what changed (changes may be git staged): @duc/schema/duc.sql + +migrations for the .sql files may need to be created by running the following the folder and adding on top of the last one: schema/migrations \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f2b72f78..e98181b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,6 +390,7 @@ dependencies = [ "js-sys", "log", "serde", + "serde-wasm-bindgen", "serde_json", "svg2pdf", "svgtypes 0.12.0", diff --git a/assets/testing/duc-files/universal.duc b/assets/testing/duc-files/universal.duc index 455f5198..8b62e9a1 100644 Binary files a/assets/testing/duc-files/universal.duc and b/assets/testing/duc-files/universal.duc differ diff --git a/packages/ducjs/src/restore/restoreElements.ts b/packages/ducjs/src/restore/restoreElements.ts index 6a440d46..dfcd95d9 100644 --- a/packages/ducjs/src/restore/restoreElements.ts +++ b/packages/ducjs/src/restore/restoreElements.ts @@ -534,8 +534,12 @@ const restoreElement = ( : null; // Create the base restored element + // Skip sizeFromPoints override for block instance elements: their stored + // width/height represents the total duplication grid, not single-cell dims. + const isBlockInstanceElement = !!element.instanceId; const sizeFromPoints = !hasBindings && + !isBlockInstanceElement && getSizeFromPoints(finalPoints.map(getScopedBezierPointFromDucPoint)); let restoredElement = restoreElementWithProperties( element, @@ -555,6 +559,7 @@ const restoreElement = ( x: element.x, y: element.y, // Only calculate size from points if we don't have bindings + // and the element is not a block instance member (dup array uses stored total dims) ...(!sizeFromPoints ? {} : { @@ -764,7 +769,7 @@ const restoreElement = ( modelType: isValidString(modelElement.modelType) || null, code: isValidString(modelElement.code) || null, fileIds: modelElement.fileIds || [], - svgPath: modelElement.svgPath || null, + thumbnail: modelElement.thumbnail instanceof Uint8Array ? modelElement.thumbnail : null, viewerState: (modelElement.viewerState || null) as Viewer3DState | null, }, localState, diff --git a/packages/ducjs/src/transform.ts b/packages/ducjs/src/transform.ts index 1b989678..bf32dc41 100644 --- a/packages/ducjs/src/transform.ts +++ b/packages/ducjs/src/transform.ts @@ -301,16 +301,110 @@ function fixElementFromRust(el: any): any { }; } +function normalizeViewer3DStateForRust(vs: any): any { + if (!vs || typeof vs !== "object") return vs; + + const n = (v: any, fallback: number) => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + + const b = (v: any, fallback: boolean) => + typeof v === "boolean" ? v : fallback; + + const s = (v: any, fallback: string) => + typeof v === "string" ? v : fallback; + + const arr3 = (v: any, fallback: [number, number, number]): [number, number, number] => + Array.isArray(v) && v.length === 3 && v.every((x: any) => typeof x === "number") + ? (v as [number, number, number]) + : fallback; + + const arr4 = (v: any, fallback: [number, number, number, number]): [number, number, number, number] => + Array.isArray(v) && v.length === 4 && v.every((x: any) => typeof x === "number") + ? (v as [number, number, number, number]) + : fallback; + + const cam = vs.camera ?? {}; + const dsp = vs.display ?? {}; + const mat = vs.material ?? {}; + const clip = vs.clipping ?? {}; + const expl = vs.explode ?? {}; + const zeb = vs.zebra ?? {}; + + const normalizeClipPlane = (cp: any) => ({ + enabled: b(cp?.enabled, false), + value: n(cp?.value, 0), + normal: cp?.normal != null ? arr3(cp.normal, [0, 0, 0]) : null, + }); + + return { + camera: { + control: s(cam.control, "orbit"), + ortho: b(cam.ortho, true), + up: s(cam.up, "Z"), + position: arr3(cam.position, [0, 0, 0]), + quaternion: arr4(cam.quaternion, [0, 0, 0, 1]), + target: arr3(cam.target, [0, 0, 0]), + zoom: n(cam.zoom, 1), + panSpeed: n(cam.panSpeed, 1), + rotateSpeed: n(cam.rotateSpeed, 1), + zoomSpeed: n(cam.zoomSpeed, 1), + holroyd: b(cam.holroyd, false), + }, + display: { + wireframe: b(dsp.wireframe, false), + transparent: b(dsp.transparent, false), + blackEdges: b(dsp.blackEdges, true), + grid: dsp.grid ?? { type: "uniform", value: false }, + axesVisible: b(dsp.axesVisible, false), + axesAtOrigin: b(dsp.axesAtOrigin, true), + }, + material: { + metalness: n(mat.metalness, 0.3), + roughness: n(mat.roughness, 0.65), + defaultOpacity: n(mat.defaultOpacity, 0.5), + edgeColor: n(mat.edgeColor, 0x707070), + ambientIntensity: n(mat.ambientIntensity, 1.0), + directIntensity: n(mat.directIntensity, 1.1), + }, + clipping: { + x: normalizeClipPlane(clip.x), + y: normalizeClipPlane(clip.y), + z: normalizeClipPlane(clip.z), + intersection: b(clip.intersection, false), + showPlanes: b(clip.showPlanes, false), + objectColorCaps: b(clip.objectColorCaps, false), + }, + explode: { + active: b(expl.active, false), + value: n(expl.value, 0), + }, + zebra: { + active: b(zeb.active, false), + stripeCount: toInteger(zeb.stripeCount, 6), + stripeDirection: n(zeb.stripeDirection, 0), + colorScheme: s(zeb.colorScheme, "blackwhite"), + opacity: n(zeb.opacity, 1), + mappingMode: s(zeb.mappingMode, "reflection"), + }, + }; +} + function fixElementToRust(el: any): any { if (!el) return el; fixStylesHatch(el, false); fixCustomDataToRust(el); - if (el.type === "model" && el.viewerState?.display?.grid) { - el.viewerState = { - ...el.viewerState, - display: { ...el.viewerState.display, grid: fixViewer3DGridToRust(el.viewerState.display.grid) }, - }; + + if (el.type === "model") { + if (el.viewerState) { + el.viewerState = normalizeViewer3DStateForRust(el.viewerState); + } + if (el.viewerState?.display?.grid) { + el.viewerState = { + ...el.viewerState, + display: { ...el.viewerState.display, grid: fixViewer3DGridToRust(el.viewerState.display.grid) }, + }; + } } // Convert TypeScript DucLine tuples [start, end] → Rust structs { start, end } @@ -381,6 +475,10 @@ function flattenPrecisionValues(obj: any): any { if (Array.isArray(obj)) return obj.map(flattenPrecisionValues); + if (ArrayBuffer.isView(obj) || obj instanceof ArrayBuffer) { + return obj; + } + if (typeof obj === "object") { const out: Record = {}; for (const key in obj) { diff --git a/packages/ducjs/src/types/elements/index.ts b/packages/ducjs/src/types/elements/index.ts index 3ee9e13c..98e61a41 100644 --- a/packages/ducjs/src/types/elements/index.ts +++ b/packages/ducjs/src/types/elements/index.ts @@ -1180,8 +1180,8 @@ export type DucModelElement = _DucElementBase & { /** Defines the source code of the model using build123d python code */ code: string | null; - /** The last known SVG path representation of the 3D model for quick rendering on the canvas */ - svgPath: string | null; + /** The last known image thumbnail of the 3D model for quick rendering on the canvas */ + thumbnail: Uint8Array | null; /** Possibly connected external files, such as STEP, STL, DXF, etc. */ fileIds: ExternalFileId[]; diff --git a/packages/ducjs/src/types/index.ts b/packages/ducjs/src/types/index.ts index 73f54e05..07104f41 100644 --- a/packages/ducjs/src/types/index.ts +++ b/packages/ducjs/src/types/index.ts @@ -171,7 +171,8 @@ export type ToolType = | "laser" | "table" | "doc" - | "pdf"; + | "pdf" + | "model"; export type ElementOrToolType = DucElementType | ToolType | "custom"; diff --git a/packages/ducjs/src/utils/elements/newElement.ts b/packages/ducjs/src/utils/elements/newElement.ts index f8aaf192..a2913d48 100644 --- a/packages/ducjs/src/utils/elements/newElement.ts +++ b/packages/ducjs/src/utils/elements/newElement.ts @@ -398,7 +398,7 @@ export const newPdfElement = (currentScope: Scope, opts: ElementConstructorOpts) export const newModelElement = (currentScope: Scope, opts: ElementConstructorOpts): NonDeleted => ({ modelType: null, code: null, - svgPath: null, + thumbnail: null, fileIds: [], viewerState: null, ..._newElementBase("model", currentScope, opts), @@ -420,6 +420,10 @@ const _deepCopyElement = (val: any, depth: number = 0) => { return val; } + if (ArrayBuffer.isView(val)) { + return (val as any).slice(0); + } + const objectType = Object.prototype.toString.call(val); if (objectType === "[object Object]") { diff --git a/packages/ducpdf/src/duc2pdf/Cargo.toml b/packages/ducpdf/src/duc2pdf/Cargo.toml index 3096369d..54bb12fa 100644 --- a/packages/ducpdf/src/duc2pdf/Cargo.toml +++ b/packages/ducpdf/src/duc2pdf/Cargo.toml @@ -33,6 +33,7 @@ web-sys = { version = "0.3", features = ["console"] } svgtypes = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde-wasm-bindgen = "0.6" log = "0.4.22" console_log = "1.0" diff --git a/packages/ducpdf/src/duc2pdf/index.ts b/packages/ducpdf/src/duc2pdf/index.ts index 84730419..4260531b 100644 --- a/packages/ducpdf/src/duc2pdf/index.ts +++ b/packages/ducpdf/src/duc2pdf/index.ts @@ -32,6 +32,7 @@ export async function initWasmFromBinary(wasmBinary: BufferSource): Promise { // Validate that required functions exist on the imported module const requiredFunctions = [ + 'convert_exported_data_to_pdf_wasm', 'convert_duc_to_pdf_rs', 'convert_duc_to_pdf_with_scale_wasm', 'convert_duc_to_pdf_crop_wasm', @@ -191,16 +193,18 @@ export async function convertDucToPdf( let ducBytes = new Uint8Array(ducData); let viewBackgroundColor; let normalizedData: ExportedDataState | null = null; + let rustPayload: unknown = null; try { const latestBlob = new Blob([ducBytes]); const parsed = await parseDuc(latestBlob); if (parsed) { - // Extract scope from parsed data - use localState.scope first, fallback to globalState.mainScope const scope = parsed?.localState?.scope || parsed?.globalState?.mainScope || 'mm'; - - // ensure that we are only working with mm on the pdf conversion logic - const normalized: ExportedDataState = normalizeForSerializationScope(parsed as unknown as ExportedDataState, 'mm', scope); + const normalized: ExportedDataState = normalizeForSerializationScope( + parsed as unknown as ExportedDataState, + 'mm', + scope, + ); normalized.localState.scope = 'mm'; normalized.globalState.mainScope = 'mm'; @@ -278,7 +282,19 @@ export async function convertDucToPdf( let result: Uint8Array; const hasFonts = fontMap.size > 0; - if (options && (options.offsetX !== undefined || options.offsetY !== undefined)) { + if (rustPayload) { + const backgroundColor = options?.backgroundColor ? options.backgroundColor.trim() : viewBackgroundColor; + result = wasm.convert_exported_data_to_pdf_wasm( + rustPayload, + options?.offsetX, + options?.offsetY, + typeof options?.width === 'number' ? options.width : undefined, + typeof options?.height === 'number' ? options.height : undefined, + backgroundColor === undefined ? undefined : backgroundColor, + typeof options?.scale === 'number' ? options.scale : undefined, + fontMap, + ); + } else if (options && (options.offsetX !== undefined || options.offsetY !== undefined)) { // Use crop mode with offset const offsetX = options.offsetX || 0; const offsetY = options.offsetY || 0; @@ -291,43 +307,43 @@ export async function convertDucToPdf( if (hasFonts) { result = scaleOption !== undefined ? wasm.convert_duc_to_pdf_crop_with_fonts_scaled_wasm( - ducBytes, - offsetX, - offsetY, - widthOption, - heightOption, - backgroundOption, - scaleOption, - fontMap - ) + ducBytes, + offsetX, + offsetY, + widthOption, + heightOption, + backgroundOption, + scaleOption, + fontMap + ) : wasm.convert_duc_to_pdf_crop_with_fonts_wasm( - ducBytes, - offsetX, - offsetY, - widthOption, - heightOption, - backgroundOption, - fontMap - ); + ducBytes, + offsetX, + offsetY, + widthOption, + heightOption, + backgroundOption, + fontMap + ); } else { result = scaleOption !== undefined ? wasm.convert_duc_to_pdf_crop_scaled_wasm( - ducBytes, - offsetX, - offsetY, - widthOption, - heightOption, - backgroundOption, - scaleOption - ) + ducBytes, + offsetX, + offsetY, + widthOption, + heightOption, + backgroundOption, + scaleOption + ) : wasm.convert_duc_to_pdf_crop_wasm( - ducBytes, - offsetX, - offsetY, - widthOption, - heightOption, - backgroundOption - ); + ducBytes, + offsetX, + offsetY, + widthOption, + heightOption, + backgroundOption + ); } } else { // Standard conversion diff --git a/packages/ducpdf/src/duc2pdf/src/builder.rs b/packages/ducpdf/src/duc2pdf/src/builder.rs index 46de215b..60a78b6a 100644 --- a/packages/ducpdf/src/duc2pdf/src/builder.rs +++ b/packages/ducpdf/src/duc2pdf/src/builder.rs @@ -2,7 +2,8 @@ use crate::scaling::DucDataScaler; use crate::streaming::stream_elements::ElementStreamer; use crate::streaming::stream_resources::ResourceStreamer; use crate::utils::freedraw_bounds::{ - calculate_freedraw_bbox, format_number, FreeDrawBounds, UNIT_EPSILON as FREEDRAW_EPSILON, + calculate_freedraw_bbox, calculate_freedraw_point_bbox, format_number, FreeDrawBounds, + UNIT_EPSILON as FREEDRAW_EPSILON, }; use crate::utils::style_resolver::StyleResolver; use crate::utils::svg_to_pdf::{svg_to_pdf, svg_to_pdf_with_dimensions}; @@ -51,6 +52,8 @@ const ROBOTO_MONO_FONT_BYTES: &[u8] = include_bytes!(concat!( "/fonts/RobotoMono-Variable.ttf" )); +const EXPORT_SANITY_LIMIT_MM: f64 = 1.0e12; + /// Resource cache for storing PDF object IDs #[derive(Default, Clone)] pub struct ResourceCache { @@ -90,6 +93,174 @@ pub struct DucToPdfBuilder { } impl DucToPdfBuilder { + fn is_export_sane_value(value: f64) -> bool { + value.is_finite() && value.abs() <= EXPORT_SANITY_LIMIT_MM + } + + fn element_id_and_type(element_wrapper: &ElementWrapper) -> (&str, &str) { + match &element_wrapper.element { + DucElementEnum::DucRectangleElement(elem) => (&elem.base.id, "rectangle"), + DucElementEnum::DucPolygonElement(elem) => (&elem.base.id, "polygon"), + DucElementEnum::DucEllipseElement(elem) => (&elem.base.id, "ellipse"), + DucElementEnum::DucEmbeddableElement(elem) => (&elem.base.id, "embeddable"), + DucElementEnum::DucPdfElement(elem) => (&elem.base.id, "pdf"), + DucElementEnum::DucTableElement(elem) => (&elem.base.id, "table"), + DucElementEnum::DucImageElement(elem) => (&elem.base.id, "image"), + DucElementEnum::DucTextElement(elem) => (&elem.base.id, "text"), + DucElementEnum::DucLinearElement(elem) => (&elem.linear_base.base.id, "line"), + DucElementEnum::DucArrowElement(elem) => (&elem.linear_base.base.id, "arrow"), + DucElementEnum::DucFreeDrawElement(elem) => (&elem.base.id, "freedraw"), + DucElementEnum::DucFrameElement(elem) => (&elem.stack_element_base.base.id, "frame"), + DucElementEnum::DucPlotElement(elem) => (&elem.stack_element_base.base.id, "plot"), + DucElementEnum::DucDocElement(elem) => (&elem.base.id, "doc"), + DucElementEnum::DucModelElement(elem) => (&elem.base.id, "model"), + } + } + + fn element_has_sane_geometry(element_wrapper: &ElementWrapper) -> bool { + let base_is_sane = |base: &duc::types::DucElementBase| { + [base.x, base.y, base.width, base.height, base.angle] + .into_iter() + .all(Self::is_export_sane_value) + }; + + match &element_wrapper.element { + DucElementEnum::DucRectangleElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucPolygonElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucEllipseElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucEmbeddableElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucPdfElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucTableElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucImageElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucTextElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucDocElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucModelElement(elem) => base_is_sane(&elem.base), + DucElementEnum::DucFrameElement(elem) => base_is_sane(&elem.stack_element_base.base), + DucElementEnum::DucPlotElement(elem) => base_is_sane(&elem.stack_element_base.base), + DucElementEnum::DucLinearElement(elem) => { + base_is_sane(&elem.linear_base.base) + && elem + .linear_base + .points + .iter() + .all(|point| Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)) + } + DucElementEnum::DucArrowElement(elem) => { + base_is_sane(&elem.linear_base.base) + && elem + .linear_base + .points + .iter() + .all(|point| Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)) + } + DucElementEnum::DucFreeDrawElement(elem) => { + base_is_sane(&elem.base) + && Self::is_export_sane_value(elem.size) + && elem + .points + .iter() + .all(|point| Self::is_export_sane_value(point.x) && Self::is_export_sane_value(point.y)) + } + } + } + + fn filter_out_unusable_elements(exported_data: &mut ExportedDataState) { + let mut dropped_elements = Vec::new(); + + exported_data.elements.retain(|element_wrapper| { + let keep = Self::element_has_sane_geometry(element_wrapper); + if !keep { + let (id, element_type) = Self::element_id_and_type(element_wrapper); + dropped_elements.push((id.to_string(), element_type.to_string())); + } + + keep + }); + + if !dropped_elements.is_empty() { + let preview = dropped_elements + .iter() + .take(5) + .map(|(id, element_type)| format!("{} ({})", id, element_type)) + .collect::>() + .join(", "); + + log_warn!( + "Skipping {} export element(s) with absurd geometry before scaling: {}", + dropped_elements.len(), + preview + ); + } + } + + fn has_usable_dimension(value: f64) -> bool { + value.is_finite() && value > FREEDRAW_EPSILON + } + + fn select_freedraw_bounds( + &self, + freedraw: &duc::types::DucFreeDrawElement, + ) -> Option { + let preferred_bounds = calculate_freedraw_bbox(freedraw); + let point_bounds = calculate_freedraw_point_bbox(&freedraw.points, freedraw.size); + + if let Some(bounds) = preferred_bounds { + if Self::has_usable_dimension(bounds.width()) && Self::has_usable_dimension(bounds.height()) { + return Some(bounds); + } + + if let Some(point_bounds) = point_bounds { + if Self::has_usable_dimension(point_bounds.width()) + && Self::has_usable_dimension(point_bounds.height()) + { + log_warn!( + "Recovered degenerate freedraw bbox for {} using point bounds (svg/path bbox: {}x{}, points bbox: {}x{})", + freedraw.base.id, + bounds.width(), + bounds.height(), + point_bounds.width(), + point_bounds.height() + ); + return Some(point_bounds); + } + } + + return Some(bounds); + } + + point_bounds + } + + fn sanitize_page_size(&self, width: f64, height: f64) -> (f64, f64) { + let fallback_bounds = self.calculate_overall_bounds(); + let fallback_width = fallback_bounds.2.abs().max(210.0); + let fallback_height = fallback_bounds.3.abs().max(297.0); + + let sanitized_width = if Self::has_usable_dimension(width) { + width + } else { + log_warn!( + "Recovered invalid PDF page width {} using fallback {}", + width, + fallback_width + ); + fallback_width + }; + + let sanitized_height = if Self::has_usable_dimension(height) { + height + } else { + log_warn!( + "Recovered invalid PDF page height {} using fallback {}", + height, + fallback_height + ); + fallback_height + }; + + (sanitized_width.max(0.001), sanitized_height.max(0.001)) + } + /// Parse color string to RGB values (0-255) using bigcolor /// Supports various color formats (hex, rgb, named colors, etc.) fn parse_color(&self, color_str: &str) -> Option<(u8, u8, u8)> { @@ -106,6 +277,8 @@ impl DucToPdfBuilder { ) -> ConversionResult { let mut document = Document::with_version("1.7"); + Self::filter_out_unusable_elements(&mut exported_data); + let crop_offset = match &options.mode { ConversionMode::Crop { offset_x, offset_y, .. @@ -779,7 +952,7 @@ impl DucToPdfBuilder { }; // Calculate bounding box using the SVG path when available for accurate bounds - let svg_document = if let Some(bounds) = calculate_freedraw_bbox(freedraw) { + let svg_document = if let Some(bounds) = self.select_freedraw_bounds(freedraw) { // Cache the calculated bounding box for later use in stream_freedraw self.context .resource_cache @@ -789,15 +962,41 @@ impl DucToPdfBuilder { let mut width = bounds.width(); let mut height = bounds.height(); - if width < FREEDRAW_EPSILON { - width = freedraw.base.width.max(FREEDRAW_EPSILON); + if !Self::has_usable_dimension(width) { + width = freedraw.base.width.abs().max(freedraw.size.abs()).max(FREEDRAW_EPSILON); + } + if !Self::has_usable_dimension(height) { + height = freedraw.base.height.abs().max(freedraw.size.abs()).max(FREEDRAW_EPSILON); } - if height < FREEDRAW_EPSILON { - height = freedraw.base.height.max(FREEDRAW_EPSILON); + + if !Self::has_usable_dimension(width) || !Self::has_usable_dimension(height) { + log_warn!( + "Skipping freedraw {} because bounds remained unusable after fallback (base={}x{}, size={}, svg_len={})", + freedraw.base.id, + freedraw.base.width, + freedraw.base.height, + freedraw.size, + svg_path.len() + ); + continue; } let width_str = format_number(width); let height_str = format_number(height); + + if width_str == "0" || height_str == "0" { + log_warn!( + "Skipping freedraw {} because formatted bounds collapsed to zero (numeric={}x{}, base={}x{}, size={})", + freedraw.base.id, + width, + height, + freedraw.base.width, + freedraw.base.height, + freedraw.size + ); + continue; + } + let translate_x = format_number(-bounds.min_x); let translate_y = format_number(-bounds.min_y); @@ -844,7 +1043,19 @@ impl DucToPdfBuilder { } Err(e) => { // Log error but continue processing other elements - log_warn!("Warning: SVG to PDF conversion failed for Freedraw element {}: {}", freedraw.base.id, e); + let svg_header_preview = svg_document + .split('>') + .next() + .unwrap_or_default(); + log_warn!( + "Warning: SVG to PDF conversion failed for Freedraw element {}: {} (base={}x{}, size={}, header='{}')", + freedraw.base.id, + e, + freedraw.base.width, + freedraw.base.height, + freedraw.size, + svg_header_preview + ); } } } @@ -1059,8 +1270,7 @@ impl DucToPdfBuilder { ) -> ConversionResult<()> { let (_x, _y, width, height) = bounds; // Use bounds directly (already scaled) - let page_width = width; - let page_height = height; + let (page_width, page_height) = self.sanitize_page_size(width, height); // Set the page height for Y-axis coordinate transformations self.page_height = page_height; @@ -1120,6 +1330,8 @@ impl DucToPdfBuilder { (width, height) }; + let (page_width, page_height) = self.sanitize_page_size(page_width, page_height); + // Set the page height for Y-axis coordinate transformations self.page_height = page_height; diff --git a/packages/ducpdf/src/duc2pdf/src/lib.rs b/packages/ducpdf/src/duc2pdf/src/lib.rs index f8dee155..0d3bc502 100644 --- a/packages/ducpdf/src/duc2pdf/src/lib.rs +++ b/packages/ducpdf/src/duc2pdf/src/lib.rs @@ -280,6 +280,18 @@ pub fn convert_duc_to_pdf_with_fonts_and_options( builder::DucToPdfBuilder::new(exported_data, normalized_options, font_data)?.build() } +pub fn convert_exported_data_to_pdf_with_fonts_and_options( + exported_data: duc::types::ExportedDataState, + options: ConversionOptions, + font_data: HashMap>, +) -> ConversionResult> { + let mut normalized_options = options; + normalized_options.background_color = + normalize_background_color(normalized_options.background_color); + + builder::DucToPdfBuilder::new(exported_data, normalized_options, font_data)?.build() +} + /// WASM binding for the main conversion function #[wasm_bindgen] pub fn convert_duc_to_pdf_rs(duc_data: &[u8]) -> Vec { @@ -527,6 +539,95 @@ fn deserialize_font_map(font_map_js: JsValue) -> HashMap> { fonts } +fn deserialize_exported_data( + exported_data_js: JsValue, +) -> Result { + serde_wasm_bindgen::from_value(exported_data_js) + .map_err(|e| format!("Failed to deserialize exported data: {}", e)) +} + +#[wasm_bindgen] +pub fn convert_exported_data_to_pdf_wasm( + exported_data_js: JsValue, + offset_x: Option, + offset_y: Option, + width: Option, + height: Option, + background_color: Option, + scale: Option, + font_map_js: JsValue, +) -> Vec { + if let Some(w) = width { + if !w.is_finite() || w <= 0.0 { + let error_info = error_handling::WasmErrorInfo { + error: format!("Invalid width: {}", w), + error_type: "ValidationError".to_string(), + details: format!("width must be a positive finite number, got {}", w), + duc_data_length: 0, + conversion_context: None, + }; + return error_handling::error_to_wasm_bytes(&error_info); + } + } + + if let Some(h) = height { + if !h.is_finite() || h <= 0.0 { + let error_info = error_handling::WasmErrorInfo { + error: format!("Invalid height: {}", h), + error_type: "ValidationError".to_string(), + details: format!("height must be a positive finite number, got {}", h), + duc_data_length: 0, + conversion_context: None, + }; + return error_handling::error_to_wasm_bytes(&error_info); + } + } + + let exported_data = match deserialize_exported_data(exported_data_js) { + Ok(data) => data, + Err(details) => { + let error_info = error_handling::WasmErrorInfo { + error: details.clone(), + error_type: "ValidationError".to_string(), + details, + duc_data_length: 0, + conversion_context: None, + }; + return error_handling::error_to_wasm_bytes(&error_info); + } + }; + + let font_data = deserialize_font_map(font_map_js); + let normalized_background = normalize_background_color(background_color); + + let mode = if offset_x.is_some() || offset_y.is_some() { + ConversionMode::Crop { + offset_x: offset_x.unwrap_or(0.0), + offset_y: offset_y.unwrap_or(0.0), + width, + height, + } + } else { + ConversionMode::Plot + }; + + let options = ConversionOptions { + mode, + scale, + background_color: normalized_background, + ..Default::default() + }; + + match convert_exported_data_to_pdf_with_fonts_and_options(exported_data, options, font_data) { + Ok(pdf_bytes) => pdf_bytes, + Err(e) => { + error_handling::log_error_details(&e, 0, "Direct exported data conversion"); + let error_info = error_handling::create_error_info(&e, 0, None); + error_handling::error_to_wasm_bytes(&error_info) + } + } +} + /// WASM binding for conversion with custom font data /// font_map_js: a JS Map mapping font family names to TTF/OTF bytes #[wasm_bindgen] diff --git a/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs b/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs index 914d0495..aeef54b5 100644 --- a/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs +++ b/packages/ducpdf/src/duc2pdf/src/streaming/stream_elements.rs @@ -103,6 +103,9 @@ pub struct ElementStreamer { font_map: HashMap, /// Map of block instances for looking up duplication arrays block_instances: HashMap, + /// Pre-computed group cell pitches for multi-element instances + /// Key is instance_id, value is (group_cell_width, group_cell_height) + group_cell_pitches: HashMap, /// Whether we should require elements to be marked as "plot" to be rendered render_only_plot_elements: bool, /// Cached ExtGState names keyed by stroke/fill opacity thousandths @@ -145,6 +148,7 @@ impl ElementStreamer { text_font, font_map, block_instances, + group_cell_pitches: HashMap::new(), render_only_plot_elements: false, ext_gstate_cache: HashMap::new(), ext_gstate_definitions: HashMap::new(), @@ -155,13 +159,14 @@ impl ElementStreamer { } } - /// Calculate duplication offsets for block instance grid rendering - /// Returns a vector of (x_offset, y_offset) tuples for each grid position - /// The first offset is always (0.0, 0.0) representing the original position + /// Calculate duplication offsets for block instance grid rendering. + /// `cell_width` and `cell_height` are the per-cell dimensions (NOT total grid). + /// Returns a vector of (x_offset, y_offset) tuples for each grid position. + /// The first offset is always (0.0, 0.0) representing the original position. pub fn get_duplication_offsets( duplication_array: &DucBlockDuplicationArray, - element_width: f64, - element_height: f64, + cell_width: f64, + cell_height: f64, ) -> Vec<(f64, f64)> { if duplication_array.row_spacing.is_nan() || duplication_array.col_spacing.is_nan() { @@ -177,8 +182,8 @@ impl ElementStreamer { let row_spacing = duplication_array.row_spacing; let col_spacing = duplication_array.col_spacing; - let stride_x = element_width + col_spacing; - let stride_y = element_height + row_spacing; + let stride_x = cell_width + col_spacing; + let stride_y = cell_height + row_spacing; let mut offsets = Vec::with_capacity(rows * cols); for row in 0..rows { @@ -191,8 +196,186 @@ impl ElementStreamer { offsets } - /// Get duplication offsets for an element by looking up its block instance - /// Returns None if element has no instance_id or no duplication array + /// Compute per-cell dimensions from total grid dimensions and a duplication array. + /// Formula: cell = (total - (n - 1) * spacing) / n + fn compute_cell_dimensions( + total_width: f64, + total_height: f64, + dup_array: &DucBlockDuplicationArray, + ) -> (f64, f64) { + let cols = dup_array.cols.max(1) as f64; + let rows = dup_array.rows.max(1) as f64; + let cell_width = (total_width - (cols - 1.0) * dup_array.col_spacing) / cols; + let cell_height = (total_height - (rows - 1.0) * dup_array.row_spacing) / rows; + (cell_width.max(0.0), cell_height.max(0.0)) + } + + fn rotate_point_around_center( + point: (f64, f64), + center: (f64, f64), + angle: f64, + ) -> (f64, f64) { + if angle == 0.0 { + return point; + } + + let cos = angle.cos(); + let sin = angle.sin(); + let dx = point.0 - center.0; + let dy = point.1 - center.1; + + ( + center.0 + dx * cos - dy * sin, + center.1 + dx * sin + dy * cos, + ) + } + + fn compute_linear_absolute_visual_bounds( + linear_base: &duc::types::DucLinearElementBase, + ) -> Option<(f64, f64, f64, f64)> { + if linear_base.points.is_empty() { + return None; + } + + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + + for point in &linear_base.points { + min_x = min_x.min(point.x); + min_y = min_y.min(point.y); + max_x = max_x.max(point.x); + max_y = max_y.max(point.y); + } + + for line in &linear_base.lines { + if let Some(handle) = &line.start.handle { + min_x = min_x.min(handle.x); + min_y = min_y.min(handle.y); + max_x = max_x.max(handle.x); + max_y = max_y.max(handle.y); + } + if let Some(handle) = &line.end.handle { + min_x = min_x.min(handle.x); + min_y = min_y.min(handle.y); + max_x = max_x.max(handle.x); + max_y = max_y.max(handle.y); + } + } + + let stroke_width = linear_base + .base + .styles + .stroke + .first() + .map(|stroke| stroke.width) + .unwrap_or(0.0); + let stroke_offset = stroke_width / 2.0; + + Some(( + linear_base.base.x + min_x - stroke_offset, + linear_base.base.y + min_y - stroke_offset, + linear_base.base.x + max_x + stroke_offset, + linear_base.base.y + max_y + stroke_offset, + )) + } + + fn get_duplication_footprint_coords( + &self, + element: &DucElementEnum, + _duplication_array: Option<&DucBlockDuplicationArray>, + ) -> (f64, f64, f64, f64, f64, f64) { + let base = Self::get_element_base(element); + let bx1 = base.x.min(base.x + base.width.abs()); + let by1 = base.y.min(base.y + base.height.abs()); + let footprint_width = base.width.abs(); + let footprint_height = base.height.abs(); + + let x1 = bx1; + let y1 = by1; + let x2 = bx1 + footprint_width; + let y2 = by1 + footprint_height; + let cx = (x1 + x2) / 2.0; + let cy = (y1 + y2) / 2.0; + + (x1, y1, x2, y2, cx, cy) + } + + fn compute_element_visual_bounds(element: &DucElementEnum) -> (f64, f64, f64, f64) { + if let DucElementEnum::DucLinearElement(l) = element { + if let Some(bounds) = Self::compute_linear_absolute_visual_bounds(&l.linear_base) { + return bounds; + } + } + let base = Self::get_element_base(element); + let x = base.x; + let y = base.y; + let w = base.width.abs(); + let h = base.height.abs(); + (x, y, x + w, y + h) + } + + pub fn precompute_group_cell_pitches(&mut self, elements: &[ElementWrapper]) { + self.group_cell_pitches.clear(); + + let mut instance_elements: HashMap> = HashMap::new(); + for ew in elements { + let base = Self::get_element_base(&ew.element); + if let Some(instance_id) = &base.instance_id { + if base.is_deleted || !base.is_visible { + continue; + } + instance_elements + .entry(instance_id.clone()) + .or_default() + .push(&ew.element); + } + } + + for (instance_id, elems) in &instance_elements { + if elems.len() <= 1 { + continue; + } + + let Some(block_instance) = self.block_instances.get(instance_id) else { + continue; + }; + let Some(dup_array) = &block_instance.duplication_array else { + continue; + }; + if dup_array.rows <= 1 && dup_array.cols <= 1 { + continue; + } + + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + + for elem in elems { + let renderable = self.get_renderable_duplication_element(elem); + let (bx1, by1, bx2, by2) = Self::compute_element_visual_bounds(&renderable); + min_x = min_x.min(bx1); + min_y = min_y.min(by1); + max_x = max_x.max(bx2); + max_y = max_y.max(by2); + } + + let group_cell_width = max_x - min_x; + let group_cell_height = max_y - min_y; + + if group_cell_width > 0.0 && group_cell_height > 0.0 { + self.group_cell_pitches + .insert(instance_id.clone(), (group_cell_width, group_cell_height)); + } + } + } + + /// Get duplication offsets for an element by looking up its block instance. + /// Returns None if element has no instance_id or no duplication array. + /// Offsets are computed using per-cell dimensions derived from the element's + /// total grid width/height. pub fn get_element_duplication_offsets( &self, element: &DucElementEnum, @@ -205,28 +388,48 @@ impl ElementStreamer { if let Some(dup_array) = &block_instance.duplication_array { // Only return offsets if there's more than one copy to render if dup_array.rows > 1 || dup_array.cols > 1 { + let (total_width, total_height) = Self::extract_element_dimensions(element); + let (elem_cell_width, elem_cell_height) = + Self::compute_cell_dimensions(total_width, total_height, dup_array); + + let (pitch_w, pitch_h) = self + .group_cell_pitches + .get(instance_id.as_str()) + .copied() + .unwrap_or((elem_cell_width, elem_cell_height)); + + let cols = dup_array.cols.max(1) as usize; + let rows = dup_array.rows.max(1) as usize; + let col_spacing = dup_array.col_spacing; + let row_spacing = dup_array.row_spacing; + + let footprint_width = pitch_w * cols as f64 + (cols as f64 - 1.0) * col_spacing; + let footprint_height = pitch_h * rows as f64 + (rows as f64 - 1.0) * row_spacing; + + let (bx1, by1, _bx2, _by2, _fcx, _fcy) = + self.get_duplication_footprint_coords(element, Some(dup_array)); + let fcx = bx1 + footprint_width / 2.0; + let fcy = by1 + footprint_height / 2.0; + let footprint_center = (fcx, fcy); + let c0 = (bx1 + pitch_w / 2.0, by1 + pitch_h / 2.0); + + let mut offsets = Vec::with_capacity(rows * cols); + for row in 0..rows { + for col in 0..cols { + let c_copy = ( + c0.0 + col as f64 * (pitch_w + col_spacing), + c0.1 + row as f64 * (pitch_h + row_spacing), + ); + let c_rotated = Self::rotate_point_around_center( + c_copy, + footprint_center, + base.angle, + ); + offsets.push((c_rotated.0 - c0.0, c_rotated.1 - c0.1)); + } + } - // Attempt to extract dimensions from the element - // This allows "gap" spacing: offset = index * (size + spacing) - let (width, height) = match element { - DucElementEnum::DucRectangleElement(r) => (r.base.width, r.base.height), - DucElementEnum::DucEllipseElement(e) => (e.base.width, e.base.height), - DucElementEnum::DucImageElement(i) => (i.base.width, i.base.height), - DucElementEnum::DucFrameElement(f) => (f.stack_element_base.base.width, f.stack_element_base.base.height), - DucElementEnum::DucPlotElement(p) => (p.stack_element_base.base.width, p.stack_element_base.base.height), - DucElementEnum::DucTableElement(t) => (t.base.width, t.base.height), - DucElementEnum::DucDocElement(d) => (d.base.width, d.base.height), - DucElementEnum::DucEmbeddableElement(e) => (e.base.width, e.base.height), - DucElementEnum::DucPolygonElement(p) => (p.base.width, p.base.height), - DucElementEnum::DucTextElement(t) => (t.base.width, t.base.height), - DucElementEnum::DucFreeDrawElement(f) => (f.base.width, f.base.height), - DucElementEnum::DucLinearElement(l) => (l.linear_base.base.width, l.linear_base.base.height), - DucElementEnum::DucArrowElement(a) => (a.linear_base.base.width, a.linear_base.base.height), - DucElementEnum::DucPdfElement(p) => (p.base.width, p.base.height), - DucElementEnum::DucModelElement(m) => (m.base.width, m.base.height), - }; - - return Some(Self::get_duplication_offsets(dup_array, width, height)); + return Some(offsets); } } } else { @@ -235,6 +438,197 @@ impl ElementStreamer { None } + /// Create a per-cell renderable element for duplication-array rendering. + /// The exported element stores total grid dimensions, but each rendered copy + /// needs the single-cell size. + pub fn get_renderable_duplication_element( + &self, + element: &DucElementEnum, + ) -> DucElementEnum { + let base = Self::get_element_base(element); + let Some(instance_id) = base.instance_id.as_ref() else { + return element.clone(); + }; + let Some(block_instance) = self.block_instances.get(instance_id) else { + return element.clone(); + }; + let Some(dup_array) = block_instance.duplication_array.as_ref() else { + return element.clone(); + }; + if dup_array.rows <= 1 && dup_array.cols <= 1 { + return element.clone(); + } + + let (total_width, total_height) = Self::extract_element_dimensions(element); + let (cell_width, cell_height) = + Self::compute_cell_dimensions(total_width, total_height, dup_array); + + Self::with_element_dimensions(element.clone(), cell_width, cell_height) + } + + /// Extract width/height from any DucElementEnum variant. + fn extract_element_dimensions(element: &DucElementEnum) -> (f64, f64) { + match element { + DucElementEnum::DucRectangleElement(r) => (r.base.width, r.base.height), + DucElementEnum::DucEllipseElement(e) => (e.base.width, e.base.height), + DucElementEnum::DucImageElement(i) => (i.base.width, i.base.height), + DucElementEnum::DucFrameElement(f) => (f.stack_element_base.base.width, f.stack_element_base.base.height), + DucElementEnum::DucPlotElement(p) => (p.stack_element_base.base.width, p.stack_element_base.base.height), + DucElementEnum::DucTableElement(t) => (t.base.width, t.base.height), + DucElementEnum::DucDocElement(d) => (d.base.width, d.base.height), + DucElementEnum::DucEmbeddableElement(e) => (e.base.width, e.base.height), + DucElementEnum::DucPolygonElement(p) => (p.base.width, p.base.height), + DucElementEnum::DucTextElement(t) => (t.base.width, t.base.height), + DucElementEnum::DucFreeDrawElement(f) => (f.base.width, f.base.height), + DucElementEnum::DucLinearElement(l) => (l.linear_base.base.width, l.linear_base.base.height), + DucElementEnum::DucArrowElement(a) => (a.linear_base.base.width, a.linear_base.base.height), + DucElementEnum::DucPdfElement(p) => (p.base.width, p.base.height), + DucElementEnum::DucModelElement(m) => (m.base.width, m.base.height), + } + } + + fn with_element_dimensions( + mut element: DucElementEnum, + width: f64, + height: f64, + ) -> DucElementEnum { + match &mut element { + DucElementEnum::DucRectangleElement(r) => { + r.base.width = width; + r.base.height = height; + } + DucElementEnum::DucPolygonElement(p) => { + p.base.width = width; + p.base.height = height; + } + DucElementEnum::DucEllipseElement(e) => { + e.base.width = width; + e.base.height = height; + } + DucElementEnum::DucEmbeddableElement(e) => { + e.base.width = width; + e.base.height = height; + } + DucElementEnum::DucPdfElement(p) => { + p.base.width = width; + p.base.height = height; + } + DucElementEnum::DucTableElement(t) => { + t.base.width = width; + t.base.height = height; + } + DucElementEnum::DucImageElement(i) => { + i.base.width = width; + i.base.height = height; + } + DucElementEnum::DucTextElement(t) => { + t.base.width = width; + t.base.height = height; + } + DucElementEnum::DucLinearElement(l) => { + l.linear_base.base.width = width; + l.linear_base.base.height = height; + } + DucElementEnum::DucArrowElement(a) => { + a.linear_base.base.width = width; + a.linear_base.base.height = height; + } + DucElementEnum::DucFreeDrawElement(f) => { + f.base.width = width; + f.base.height = height; + } + DucElementEnum::DucFrameElement(f) => { + f.stack_element_base.base.width = width; + f.stack_element_base.base.height = height; + } + DucElementEnum::DucPlotElement(p) => { + p.stack_element_base.base.width = width; + p.stack_element_base.base.height = height; + } + DucElementEnum::DucDocElement(d) => { + d.base.width = width; + d.base.height = height; + } + DucElementEnum::DucModelElement(m) => { + m.base.width = width; + m.base.height = height; + } + } + + element + } + + fn with_element_position( + mut element: DucElementEnum, + x: f64, + y: f64, + ) -> DucElementEnum { + match &mut element { + DucElementEnum::DucRectangleElement(r) => { + r.base.x = x; + r.base.y = y; + } + DucElementEnum::DucPolygonElement(p) => { + p.base.x = x; + p.base.y = y; + } + DucElementEnum::DucEllipseElement(e) => { + e.base.x = x; + e.base.y = y; + } + DucElementEnum::DucEmbeddableElement(e) => { + e.base.x = x; + e.base.y = y; + } + DucElementEnum::DucPdfElement(p) => { + p.base.x = x; + p.base.y = y; + } + DucElementEnum::DucTableElement(t) => { + t.base.x = x; + t.base.y = y; + } + DucElementEnum::DucImageElement(i) => { + i.base.x = x; + i.base.y = y; + } + DucElementEnum::DucTextElement(t) => { + t.base.x = x; + t.base.y = y; + } + DucElementEnum::DucLinearElement(l) => { + l.linear_base.base.x = x; + l.linear_base.base.y = y; + } + DucElementEnum::DucArrowElement(a) => { + a.linear_base.base.x = x; + a.linear_base.base.y = y; + } + DucElementEnum::DucFreeDrawElement(f) => { + f.base.x = x; + f.base.y = y; + } + DucElementEnum::DucFrameElement(f) => { + f.stack_element_base.base.x = x; + f.stack_element_base.base.y = y; + } + DucElementEnum::DucPlotElement(p) => { + p.stack_element_base.base.x = x; + p.stack_element_base.base.y = y; + } + DucElementEnum::DucDocElement(d) => { + d.base.x = x; + d.base.y = y; + } + DucElementEnum::DucModelElement(m) => { + m.base.x = x; + m.base.y = y; + } + } + + element + } + /// Update the active font used for text rendering pub fn set_text_font(&mut self, font_resource_name: String, font: Font) { self.font_resource_name = font_resource_name; @@ -339,7 +733,7 @@ impl ElementStreamer { let is_plot_mode = matches!(self.current_mode, StreamMode::Plot); let (_bounds_x, _bounds_y, _bounds_width, _bounds_height) = bounds; - + self.precompute_group_cell_pitches(elements); // Filter and sort elements by z-index and visibility criteria let mut filtered_elements: Vec<_> = elements @@ -416,16 +810,24 @@ impl ElementStreamer { clip_applied = clip_active; } - // Get duplication offsets (default to single (0,0) if none) + let renderable_element = self + .get_renderable_duplication_element(&element_wrapper.element); + let offsets = self .get_element_duplication_offsets(&element_wrapper.element) .unwrap_or_else(|| vec![(0.0, 0.0)]); + let renderable_base = Self::get_element_base(&renderable_element); + for (x_off, y_off) in offsets { - // Stream the element with duplication offset applied AFTER its own transform - // so the offset is not rotated or scaled by the element transform. + let positioned_renderable_element = Self::with_element_position( + renderable_element.clone(), + renderable_base.x + x_off, + renderable_base.y + y_off, + ); + let element_ops = self.stream_element_with_resources( - &element_wrapper.element, + &positioned_renderable_element, local_state, all_elements, document, @@ -433,7 +835,7 @@ impl ElementStreamer { hatching_manager, pdf_embedder, image_manager, - Some((x_off, y_off)), + None, )?; all_operations.extend(element_ops); diff --git a/packages/ducpdf/src/duc2pdf/src/utils/freedraw_bounds.rs b/packages/ducpdf/src/duc2pdf/src/utils/freedraw_bounds.rs index 02f469c3..8f6dcf36 100644 --- a/packages/ducpdf/src/duc2pdf/src/utils/freedraw_bounds.rs +++ b/packages/ducpdf/src/duc2pdf/src/utils/freedraw_bounds.rs @@ -237,7 +237,10 @@ pub(crate) fn calculate_freedraw_bbox(freedraw: &DucFreeDrawElement) -> Option Option { +pub(crate) fn calculate_freedraw_point_bbox( + points: &[DucPoint], + stroke_size: f64, +) -> Option { if points.is_empty() { return None; } @@ -421,11 +424,23 @@ fn calculate_svg_path_bbox(path_data: &str) -> Option { } pub(crate) fn format_number(value: f64) -> String { + if !value.is_finite() { + return "0".to_string(); + } + if value.abs() < UNIT_EPSILON { return "0".to_string(); } - let mut s = format!("{:.6}", value); + let precision = if value.abs() < 0.000_001 { + 12 + } else if value.abs() < 0.001 { + 9 + } else { + 6 + }; + + let mut s = format!("{value:.precision$}"); while s.contains('.') && s.ends_with('0') { s.pop(); } @@ -438,3 +453,14 @@ pub(crate) fn format_number(value: f64) -> String { s } } + +#[cfg(test)] +mod tests { + use super::format_number; + + #[test] + fn preserves_tiny_positive_values() { + assert_eq!(format_number(0.000_000_4), "0.0000004"); + assert_eq!(format_number(0.000_000_000_4), "0"); + } +} diff --git a/packages/ducpy/src/ducpy/classes/ElementsClass.py b/packages/ducpy/src/ducpy/classes/ElementsClass.py index 1d709af3..8ece91e5 100644 --- a/packages/ducpy/src/ducpy/classes/ElementsClass.py +++ b/packages/ducpy/src/ducpy/classes/ElementsClass.py @@ -479,7 +479,7 @@ class DucModelElement: file_ids: List[str] model_type: Optional[str] = None code: Optional[str] = None - svg_path: Optional[str] = None + thumbnail: Optional[bytes] = None viewer_state: Optional[Viewer3DState] = None diff --git a/packages/ducrs/src/parse.rs b/packages/ducrs/src/parse.rs index 9b62db83..6eaa8439 100644 --- a/packages/ducrs/src/parse.rs +++ b/packages/ducrs/src/parse.rs @@ -1506,10 +1506,10 @@ fn read_table_element(conn: &Connection, base: DucElementBase) -> ParseResult ParseResult { let id = base.id.clone(); let mut stmt = conn.prepare_cached( - "SELECT model_type, code, svg_path FROM element_model WHERE element_id = ?1" + "SELECT model_type, code, thumbnail FROM element_model WHERE element_id = ?1" )?; - let (model_type, code, svg_path) = stmt.query_row(params![id], |row| { - Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)) + let (model_type, code, thumbnail) = stmt.query_row(params![id], |row| { + Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>>(2)?)) })?; let mut f_stmt = conn.prepare_cached( @@ -1521,7 +1521,7 @@ fn read_model_element(conn: &Connection, base: DucElementBase) -> ParseResult SerializeResult<()> { tx.execute( - "INSERT INTO element_model (element_id, model_type, code, svg_path) + "INSERT INTO element_model (element_id, model_type, code, thumbnail) VALUES (?1, ?2, ?3, ?4)", - params![e.base.id, e.model_type, e.code, e.svg_path], + params![e.base.id, e.model_type, e.code, e.thumbnail], )?; { diff --git a/packages/ducrs/src/types.rs b/packages/ducrs/src/types.rs index 30192950..3f178dc0 100644 --- a/packages/ducrs/src/types.rs +++ b/packages/ducrs/src/types.rs @@ -1185,8 +1185,9 @@ pub struct DucModelElement { pub model_type: Option, /** Defines the source code of the model using build123d python code */ pub code: Option, - /** The last known SVG path representation of the 3D model for quick rendering on the canvas */ - pub svg_path: Option, + /** The last known image thumbnail of the 3D model for quick rendering on the canvas */ + #[serde(with = "serde_bytes", default, skip_serializing_if = "Option::is_none")] + pub thumbnail: Option>, /** Possibly connected external files, such as STEP, STL, DXF, etc. */ pub file_ids: Vec, /** The last known 3D viewer state for the model */ diff --git a/schema/duc.sql b/schema/duc.sql index cf555a93..4dc4c521 100644 --- a/schema/duc.sql +++ b/schema/duc.sql @@ -716,7 +716,7 @@ CREATE TABLE element_model ( element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE, model_type TEXT, -- e.g. PYTHON, DXF, IFC, STL, OBJ, STEP code TEXT, -- build123d python source code - svg_path TEXT -- cached SVG for canvas rendering + thumbnail BLOB -- cached image thumbnail for canvas rendering ); -- no WITHOUT ROWID: implicit integer rowid needed for FTS5 content sync CREATE INDEX idx_element_model_type ON element_model(model_type); diff --git a/schema/migrations/3000003_to_3000004.sql b/schema/migrations/3000003_to_3000004.sql new file mode 100644 index 00000000..efb12ccb --- /dev/null +++ b/schema/migrations/3000003_to_3000004.sql @@ -0,0 +1,20 @@ +-- Migration: 3000003 → 3000004 +-- Rename element_model.svg_path (TEXT) to thumbnail (BLOB). +-- SQLite does not support ALTER COLUMN, so we recreate the table. + +CREATE TABLE element_model_new ( + element_id TEXT PRIMARY KEY REFERENCES elements(id) ON DELETE CASCADE, + model_type TEXT, + code TEXT, + thumbnail BLOB +); + +INSERT INTO element_model_new (element_id, model_type, code, thumbnail) +SELECT element_id, model_type, code, CAST(svg_path AS BLOB) +FROM element_model; + +DROP TABLE element_model; + +ALTER TABLE element_model_new RENAME TO element_model; + +CREATE INDEX idx_element_model_type ON element_model(model_type);