
import { fabric } from './fabric';
import { emitAfter, emitAsukaEvent, emitAsukaEventCancelable, emitBefore, onAsukaEvent } from './events';
import { getStandardPrices, evaluatePricingRulesForObjects } from './pricingEngine';
import { getElementPriceBreakdown, normalizePriceInput } from './pricingValue';
import { synchronizeStoredElementVariationRuntime } from './elementVariationRuntime';
import type { AsukaElementVariationApplyInput } from './elementVariationSync';
import type { DrawingAreaPolicy } from '../types/drawingAreas';
import type { AsukaCustomFontConfig, AsukaGoogleFontConfig, AsukaRegisteredFont, AsukaSystemFontConfig } from '../types/options';
import { applyAsukaControls as applyAsukaBaseControls, applyAsukaControlsDeep, debugDescribeControls, sanitizeAsukaObjectControls, sanitizeFabricControls } from './canvas/asukaControls';
import { DEFAULT_ASUKA_UI_SCALING, syncCanvasUiScaling } from './canvas/visualScaling';
import { addFabricObjectAtIndex } from './canvas/fabricStack';
import {
  applyFabricVariationFrame,
  ensureFabricVariationFrame,
  FABRIC_VARIATION_FRAME_PROPERTY,
  hasFabricVariationFrame,
  refreshFabricVariationFrame,
} from './canvas/fabricVariationGeometry';
import { RuntimeObjectIndex } from './runtime/runtimeObjectIndex';
import { createCurvedGuideRuntime } from './runtime/curvedGuideRuntime';
import { createSelectionOutlineRuntime } from './runtime/selectionOutlineRuntime';
import { createSnapLinesRuntime } from './runtime/snapLinesRuntime';
import { createClipPathsRuntime } from './runtime/clipPathsRuntime';
import { isExportHelperObject, isRuntimeHelperRole } from './runtime/runtimeRoles';
import { attachSelectionEventsRuntime } from './runtime/selectionEventsRuntime';
import { attachTransformRuntime } from './runtime/transformRuntime';
import { attachObjectModifiedRuntime } from './runtime/objectModifiedRuntime';
import { createFixedSlotsRuntime } from './runtime/fixedSlotsRuntime';
import { attachTextEditingRuntime } from './runtime/textEditingRuntime';
import { createDocumentActions } from './documentActions';
import { createRendererRuntime } from './rendererRuntime';
import { createDocumentNamespace, createRuntimeNamespace } from './designerNamespaces';
import { attachObjectCollectionRuntime } from './runtime/objectCollectionRuntime';
import { createDrawingAreasLifecycleRuntime } from './runtime/drawingAreasLifecycleRuntime';
import { createDrawingAreasInteractionRuntime } from './runtime/drawingAreasInteractionRuntime';
import { createRendererLifecycleRuntime } from './runtime/rendererLifecycleRuntime';
import {
  captureFabricLayerInteractionState,
  configureFabricElementInteraction,
  FABRIC_LAYER_LOCK_PROPERTY,
  FABRIC_LAYER_LOCK_RESTORE_PROPERTY,
  getFabricLayerLockRestoreState,
  isFabricElementObject,
  resolveFabricLayerUnlockState,
} from './fabricElementInteraction';
import { canLoadPdfSupport, ensurePdfSupport, isPdfAvailable } from './pdfRuntime';
import { asuka2DPerfDebug, asukaHistoryDebug, getAsukaDebugPayloadSize, isAsuka2DPerfDebugEnabled, summarizeHistoryInfo, summarizeHistoryTrack } from './historyDebug';
import { isStateRestoringNow } from './viewSnapshots';
import { emitRenderEngineEvent, type AsukaObjectTransform, type AsukaPlacementStatus } from './renderEngine';
import { createAsukaPlacementSessionController } from './placementSession';
import {
  addImageFromFile as addCoreImageFromFile,
  addImageFromUrl as addCoreImageFromUrl,
  addSvgFromString as addCoreSvgFromString,
  addSvgFromUrl as addCoreSvgFromUrl,
} from './images';
import {
  computeCurvedTextSelectionBounds,
  type AsukaCurvedGlyphGeometry,
} from './curvedTextGeometry';
import { computeTightTextboxWidth, isFabricTextboxObject } from './textboxGeometry';
// ---------------------------------------------------------------------------
// Internal patch flags (avoid mutating frozen/non-extensible Fabric objects)
// ---------------------------------------------------------------------------
let __ASUKA_TO_OBJECT_PATCHED__ = false;

// ---------------------------------------------------------------------------
// Elements loader guard (dev): ensure variations/image loading remains centralized in core/designer.
// ---------------------------------------------------------------------------
const __ASUKA_ELEMENTS_LOADER_OWNER_KEY = '__ASUKA_ELEMENTS_LOADER_OWNER__';
const __ASUKA_ELEMENTS_LOADER_ACTIVE_KEY = '__ASUKA_ELEMENTS_LOADER_ACTIVE__';

// Cache intrinsic image sizes to keep Element instances stable across view switches.
const __asukaImageSizeCache = new Map<string, { w: number; h: number }>();
const __asukaVariationRequestVersions = new Map<string, number>();

const __beginVariationRequest = (key: string): number => {
  const normalized = String(key ?? '').trim() || 'unknown';
  const version = (__asukaVariationRequestVersions.get(normalized) ?? 0) + 1;
  __asukaVariationRequestVersions.set(normalized, version);
  return version;
};

const __isCurrentVariationRequest = (key: string, version: number): boolean => (
  __asukaVariationRequestVersions.get(String(key ?? '').trim() || 'unknown') === version
);

const asukaApplyControls = applyAsukaBaseControls;
type PositionStatus = 'inside' | 'partial' | 'outside';
try {
  const g: any = globalThis as any;
  if (g[__ASUKA_ELEMENTS_LOADER_OWNER_KEY] && g[__ASUKA_ELEMENTS_LOADER_OWNER_KEY] !== 'core/designer') {
    void 0;
  } else {
    g[__ASUKA_ELEMENTS_LOADER_OWNER_KEY] = 'core/designer';
  }
} catch {}

const __withElementsLoaderGuard = async <T,>(fn: () => Promise<T>): Promise<T> => {
  const g: any = globalThis as any;
  const prev = g[__ASUKA_ELEMENTS_LOADER_ACTIVE_KEY];
  g[__ASUKA_ELEMENTS_LOADER_ACTIVE_KEY] = true;
  try {
    return await fn();
  } finally {
    g[__ASUKA_ELEMENTS_LOADER_ACTIVE_KEY] = prev;
  }
};


const __decodeSvgDataUrl = (dataUrl: string): string | null => {
  try {
    const raw = String(dataUrl ?? '');
    if (!raw.startsWith('data:')) return null;
    const comma = raw.indexOf(',');
    if (comma < 0) return null;
    const meta = raw.slice(0, comma);
    const payload = raw.slice(comma + 1);
    return /;base64/i.test(meta) ? (typeof atob === 'function' ? atob(payload.trim()) : null) : decodeURIComponent(payload);
  } catch {
    return null;
  }
};

const __normalizeSvgLoadResult = (result: any): { objects: any[] | null; options: any } => {
  if (!result) return { objects: null, options: null };
  if (typeof result === 'object' && !Array.isArray(result)) {
    if (Array.isArray((result as any).objects)) return { objects: (result as any).objects, options: (result as any).options ?? null };
    if ((result as any).svg && Array.isArray((result as any).svg.objects)) return { objects: (result as any).svg.objects, options: (result as any).svg.options ?? null };
  }
  if (Array.isArray(result) && Array.isArray(result[0])) return { objects: result[0], options: result[1] ?? null };
  return { objects: Array.isArray(result) ? result : null, options: null };
};

const __safeGroupSvgAny = (objectsLike: any, optionsLike: any): any | null => {
  try {
    let objects = objectsLike;
    let options = optionsLike;
    if (objectsLike && typeof objectsLike === 'object' && !Array.isArray(objectsLike) && Array.isArray((objectsLike as any).objects)) {
      objects = (objectsLike as any).objects;
      options = (objectsLike as any).options ?? optionsLike;
    } else if (Array.isArray(objectsLike) && Array.isArray(objectsLike[0]) && typeof objectsLike[1] === 'object') {
      objects = objectsLike[0];
      options = objectsLike[1];
    }
    const list = Array.isArray(objects) ? objects.filter(Boolean) : [];
    if (!list.length) return null;
    const groupFn = (fabric as any)?.util?.groupSVGElements;
    if (typeof groupFn === 'function') {
      const grouped = groupFn(list, options ?? {});
      if (grouped && typeof (grouped as any).render === 'function') return grouped;
    }
    const GroupCtor = (fabric as any)?.Group;
    if (typeof GroupCtor === 'function') {
      const grouped = new GroupCtor(list, options ?? {});
      if (grouped && typeof (grouped as any).render === 'function') return grouped;
    }
    return list[0] ?? null;
  } catch {
    return null;
  }
};
;(globalThis as any).__asukaSafeGroupSvgAny = (globalThis as any).__asukaSafeGroupSvgAny ?? __safeGroupSvgAny;

const __createImageFromUrlAny = async (url: string): Promise<any | null> => {
  try {
    const ImageCtor: any = (fabric as any).FabricImage ?? (fabric as any).Image;
    if (!ImageCtor?.fromURL) return null;
    const res = ImageCtor.fromURL(String(url), { crossOrigin: 'anonymous' });
    if (res && typeof res.then === 'function') return (await res) ?? null;
    return await new Promise((resolve) => {
      const timer = setTimeout(() => resolve(null), 8000);
      ImageCtor.fromURL(String(url), (image: any) => {
        clearTimeout(timer);
        resolve(image ?? null);
      }, { crossOrigin: 'anonymous' });
    });
  } catch {
    return null;
  }
};

const __loadSvgFromStringAny = async (svgText: string): Promise<any | null> => {
  const loader: any = (fabric as any).loadSVGFromString;
  if (typeof loader !== 'function') return null;
  try {
    const res = loader(svgText);
    if (res && typeof res.then === 'function') {
      const normalized = __normalizeSvgLoadResult(await res);
      return normalized.objects?.length ? __safeGroupSvgAny(normalized.objects, normalized.options) : null;
    }
  } catch {}
  return await new Promise((resolve) => {
    const timer = setTimeout(() => resolve(null), 8000);
    loader(svgText, (objectsLike: any, optionsLike: any) => {
      clearTimeout(timer);
      const normalized = __normalizeSvgLoadResult({ objects: objectsLike, options: optionsLike });
      resolve(normalized.objects?.length ? __safeGroupSvgAny(normalized.objects, normalized.options) : null);
    });
  });
};

const __loadSvgFromUrlAny = async (url: string): Promise<any | null> => {
  const loader: any = (fabric as any).loadSVGFromURL;
  if (typeof loader !== 'function') return null;
  try {
    const res = loader(url);
    if (res && typeof res.then === 'function') {
      const normalized = __normalizeSvgLoadResult(await res);
      return normalized.objects?.length ? __safeGroupSvgAny(normalized.objects, normalized.options) : null;
    }
  } catch {}
  return await new Promise((resolve) => {
    const timer = setTimeout(() => resolve(null), 8000);
    loader(url, (objectsLike: any, optionsLike: any) => {
      clearTimeout(timer);
      const normalized = __normalizeSvgLoadResult({ objects: objectsLike, options: optionsLike });
      resolve(normalized.objects?.length ? __safeGroupSvgAny(normalized.objects, normalized.options) : null);
    });
  });
};

const __createFabricObjectFromUrl = async (url: string): Promise<any> => {
  const u = String(url ?? '').trim();
  const clean = u.split('#')[0].split('?')[0].toLowerCase();
  const isSvg = u.startsWith('data:image/svg+xml') || clean.endsWith('.svg');
  if (isSvg) {
    const svgText = u.startsWith('data:') ? __decodeSvgDataUrl(u) : null;
    const svgObj = svgText ? await __loadSvgFromStringAny(svgText) : await __loadSvgFromUrlAny(u);
    if (svgObj) return svgObj;
  }
  const imageObj = await __createImageFromUrlAny(u);
  if (!imageObj) throw new Error('Image.fromURL returned null');
  return imageObj;
};


import { useAsukaStore } from '../store/asukaStore';
import { DEFAULT_PERMISSIONS, getEffectivePermissions, type AsukaPermissions } from './permissions';
import { dedupeFonts, loadRegisteredFont, normalizeCustomFont, normalizeGoogleFont, normalizeSystemFont } from './fonts';


function isDesignerPermissionDenied(key: keyof AsukaPermissions, payload: any = {}): boolean {
  try {
    const state: any = useAsukaStore.getState?.() ?? {};
    const effective = getEffectivePermissions(
      (state.permissions ?? DEFAULT_PERMISSIONS) as AsukaPermissions,
      (state.permissionsByView?.[state.activeViewId ?? ''] ?? null) as any,
    );
    if ((effective as any)[key]) return false;
  } catch {
    return false;
  }
  const reason = payload?.reason ?? `Permission denied: ${String(key)}`;
  const ok = emitAsukaEventCancelable('before:permission.denied', { key, reason, ...payload });
  if (!ok) return false;
  emitAsukaEvent('after:permission.denied', { key, reason, ...payload });
  return true;
}


function isDesignerPatchDenied(patch: Record<string, any> | null | undefined, payload: any = {}): boolean {
  if (!patch || typeof patch !== 'object') return false;
  if ((patch as any).__skipPermissionCheck === true) return false;
  const keys = new Set(Object.keys(patch));
  const hasAny = (names: string[]) => names.some((name) => keys.has(name));

  if (hasAny(['left', 'top', 'x', 'y']) && isDesignerPermissionDenied('canMoveObject', { ...payload, patch, reason: 'Cannot move objects' })) return true;
  if (hasAny(['width', 'height', 'scaleX', 'scaleY', 'skewX', 'skewY']) && isDesignerPermissionDenied('canResizeObject', { ...payload, patch, reason: 'Cannot resize objects' })) return true;
  if (hasAny(['angle', 'flipX', 'flipY']) && isDesignerPermissionDenied('canRotateObject', { ...payload, patch, reason: 'Cannot rotate objects' })) return true;

  if (hasAny(['fill', 'stroke', 'strokeWidth', 'opacity', 'backgroundColor', 'shadow'])) {
    if (isDesignerPermissionDenied('canUseColors', { ...payload, patch, reason: 'Cannot use colors' })) return true;
    if (isDesignerPermissionDenied('canEditColors', { ...payload, patch, reason: 'Cannot edit colors' })) return true;
    if (isDesignerPermissionDenied('canEditObjectColors', { ...payload, patch, reason: 'Cannot edit object colors' })) return true;
  }

  if (hasAny(['text', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'underline', 'linethrough', 'overline', 'charSpacing', 'lineHeight', 'textAlign'])) {
    if (isDesignerPermissionDenied('canEditText', { ...payload, patch, reason: 'Cannot edit text' })) return true;
    if (isDesignerPermissionDenied('canEditObjectText', { ...payload, patch, reason: 'Cannot edit text objects' })) return true;
    if (hasAny(['fontFamily']) && isDesignerPermissionDenied('canEditFontFamily', { ...payload, patch, reason: 'Cannot edit font family' })) return true;
    if (hasAny(['fontSize', 'fontWeight', 'fontStyle', 'underline', 'linethrough', 'overline', 'charSpacing', 'lineHeight', 'textAlign']) && isDesignerPermissionDenied('canEditTextFormatting', { ...payload, patch, reason: 'Cannot edit text formatting' })) return true;
  }

  if (hasAny(['filters', 'cropX', 'cropY', 'src'])) {
    if (isDesignerPermissionDenied('canUseStudio', { ...payload, patch, reason: 'Cannot use studio' })) return true;
    if (isDesignerPermissionDenied('canApplyStudioEffects', { ...payload, patch, reason: 'Cannot apply studio effects' })) return true;
    if (isDesignerPermissionDenied('canEditObjectStudio', { ...payload, patch, reason: 'Cannot edit object studio effects' })) return true;
  }
  return false;
}

const debugElementVariations = (_label: string, _payload?: any) => {
  try {
    void 0;
  } catch {}
};

const makeElementId = () => `el-${Math.random().toString(36).slice(2, 8)}-${Date.now()}`;
const makeElementFamilyId = () => `fam-${Math.random().toString(36).slice(2, 8)}-${Date.now()}`;
const findVariationDeepById = (variations: any[], vid: string): any | null => {
  const wanted = String(vid ?? '').trim();
  if (!wanted) return null;
  for (const v of variations ?? []) {
    if (!v) continue;
    if (String((v as any)?.id ?? '').trim() === wanted) return v;
    const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
    const found = kids.length ? findVariationDeepById(kids, wanted) : null;
    if (found) return found;
  }
  return null;
};
const resolveElementRecord = (store: any, identifier: string, fallbackName = ''): any | null => {
  const wanted = String(identifier ?? '').trim();
  if (!store) return null;
  const direct = wanted ? store.findElement?.(wanted) : null;
  if (direct) return direct;
  const all = Array.isArray(store?.elements) ? store.elements : [];
  if (fallbackName) {
    return all.find((e: any) => String((e as any)?.name ?? '').trim() === String(fallbackName).trim()) ?? null;
  }
  return null;
};

import { getColorScope, getPaletteForActiveMethodOrFallback, getSimilarColorAsync } from './printing';
import { exportViewSnapshots, harmonizeSnapshotsAcrossDocument, getSnapshotForView, importViewSnapshots, saveSnapshotForView, setSnapshotDrawingAreasForView, switchViewWithCanvas, syncSnapshotObjectsFromDocumentView } from './viewSnapshots';
import { getCanvasVisualScale } from './canvas/visualScaling';

const ASUKA_CONTROLS_DEBUG = false;

function syncDocumentViewportState(viewId: string | null, canvas: any): void {
  const id = String(viewId ?? '').trim();
  if (!id || !canvas) return;
  try {
    const vt = (canvas.viewportTransform ?? [1, 0, 0, 1, 0, 0]) as number[];
    const zoom = Number(canvas.getZoom?.() ?? vt?.[0] ?? 1) || 1;
    useAsukaStore.getState().setDocumentViewportForView?.(id, {
      zoom,
      panX: Number(vt?.[4] ?? 0) || 0,
      panY: Number(vt?.[5] ?? 0) || 0,
    } as any);
  } catch {}
}

function syncDocumentSelectionState(canvas: any, activeDrawingAreaId: string | null): void {
  try {
    const active = canvas?.getActiveObjects?.() ?? [];
    const ids = (Array.isArray(active) ? active : [])
      .map((obj: any) => String(obj?._asukaObjectId ?? obj?.id ?? '').trim())
      .filter(Boolean);
    useAsukaStore.getState().setDocumentSelection?.({ activeObjectIds: ids, activeDrawingAreaId: activeDrawingAreaId ?? null } as any);
  } catch {}
}


function getReadableInsertionScale(canvas: any): number {
  try {
    return Math.max(0.01, Number(getCanvasVisualScale(canvas)) || 1);
  } catch {
    return 1;
  }
}

function ensureObjectReadableOnInsert(obj: any, canvas: any, options?: any) {
  if (!obj || !canvas) return;
  const hasExplicitGeometry = [
    options?.scaleX,
    options?.scaleY,
    options?.width,
    options?.height,
    options?.fontSize,
    options?.radius,
    options?.rx,
    options?.ry,
  ].some((v) => typeof v === 'number' && Number.isFinite(v));
  if (hasExplicitGeometry) return;

  const visualScale = getReadableInsertionScale(canvas);
  const effective = Math.max(0.01, visualScale);
  const lower = canvas?.getElement?.() ?? canvas?.lowerCanvasEl ?? null;
  const hostWidth = Number(lower?.clientWidth || canvas?.getWidth?.() || 0) || 0;
  const hostHeight = Number(lower?.clientHeight || canvas?.getHeight?.() || 0) || 0;
  const targetScreen = Math.min(260, Math.max(120, Math.min(hostWidth, hostHeight) * 0.18 || 160));
  try { obj.setCoords?.(); } catch {}
  const rect = obj.getBoundingRect?.(false, true) ?? null;
  const objectScreenSize = rect ? Math.max(Number(rect.width) || 0, Number(rect.height) || 0) * effective : 0;
  if (objectScreenSize >= targetScreen * 0.9) return;
  const ratio = Math.min(12, Math.max(1, targetScreen / Math.max(1, objectScreenSize || 1)));

  if (typeof obj.fontSize === 'number' && isFabricTextboxObject(obj)) {
    try {
      const previousFontSize = Math.max(1, Number(obj.fontSize) || 1);
      const nextFontSize = Math.max(14, previousFontSize * ratio);
      const fontRatio = nextFontSize / previousFontSize;
      obj.set?.({
        fontSize: nextFontSize,
        width: Math.max(Number(obj.minWidth) || 20, (Number(obj.width) || 20) * fontRatio),
      });
    } catch {}
  } else {
    try { obj.scaleX = (Number(obj.scaleX) || 1) * ratio; } catch {}
    try { obj.scaleY = (Number(obj.scaleY) || 1) * ratio; } catch {}
  }
  try { obj.setCoords?.(); } catch {}
}

// ---------------------------------------------------------------------------

// AsukaDesigner core (monolith) — structured sections for easier navigation.
//
// Sections:
//   1) Types
//   2) Curved-text forward declarations & controls
//   3) Canvas init & core helpers
//   4) Objects/actions
//   5) Views & snapshots
//   6) History
//   7) Public API assembly

// #region Types
/** Lightweight rectangle shape options accepted by {@link DesignerInstance.addRect}. */
export interface DesignerRectOptions {
  [key: string]: unknown;
}

/** Text shape options accepted by {@link DesignerInstance.addText}. */
export interface DesignerTextOptions {
  [key: string]: unknown;
}

/** Engine-neutral basic shape payload used by the Fabric adapter. */
export interface DesignerShapeOptions {
  kind: 'rect' | 'circle' | 'triangle' | 'line' | 'star';
  fill?: string;
  stroke?: string;
  strokeWidth?: number;
  opacity?: number;
  rectRadius?: number;
  starSpikes?: number;
  starInnerRatio?: number;
  width?: number;
  height?: number;
  printAreaId?: string | null;
  metadata?: Record<string, unknown>;
}

export interface DesignerDrawingAreaDraftOptions {
  kind: 'rect' | 'ellipse' | 'triangle' | 'star' | 'svg';
  idPrefix?: string;
  svgText?: string;
  bounds?: { left: number; top: number; width: number; height: number };
}

/** Download/export options accepted by {@link DesignerInstance.download}. */
export interface DesignerDownloadOptions {
  [key: string]: unknown;
}

/** Generic drawing area runtime payload. */
export interface DesignerDrawingAreaRuntime {
  objectIds: string[];
  placeholderId?: string;
  [key: string]: unknown;
}

/** Aggregated price breakdown for one drawing area. */
export interface DesignerDrawingAreaPriceUiBreakdown {
  base: number;
  standard: number;
  rules: number;
  area: number;
  total: number;
}

/** Export source accepted by low-level download helpers. */
export type DesignerExportSource = unknown;

/**
 * Public interface of the low-level Fabric-backed designer engine.
 *
 * Higher-level integrations usually go through `createAsukaDesigner(...).api`,
 * but this interface remains useful for advanced runtime integrations and for
 * TypeDoc pages documenting the underlying engine.
 */
export interface DesignerInstance {
  [key: string]: any;
  canvas?: any;
  /** Return the underlying Fabric canvas instance. */
  getCanvas(): any;

  /** Add a normalized object to the document-first state/runtime pipeline. */
  addObject(object: any, options?: any): any;
  /** Patch a normalized object already tracked by the state/runtime pipeline. */
  updateObject(objectId: string, patch: Record<string, any>): any | null;

  /** Return one object by its stable Asuka object id. */
  getObjectById(objectId: string): any | null;
  /** Force one object's visibility. */
  setObjectVisible(objectId: string, visible: boolean): void;
  /** Toggle one object's visibility. */
  toggleObjectVisible(objectId: string): void;
  /** Force one object's lock state. */
  setObjectLocked(objectId: string, locked: boolean): void;
  /** Toggle one object's lock state. */
  toggleObjectLocked(objectId: string): void;
  /** Remove one object by id. */
  removeObject(objectId: string): void;

  /** Switch the canvas element to a fixed pixel size. */
  setCanvasFixedSize(width: number, height: number): void;
  /** Restore the responsive fit-to-host behavior. */
  setCanvasFitToHost(): void;
  /** Resize the viewport to match the current host size. */
  resizeToHost(): void;
  /** Recompute readable control/helper sizes after a visual resize. */
  refreshUiScaling(): void;
  /** Recreate helper visuals after `clear()` or `loadFromJSON()`. */
  refreshAfterViewLoad(): void;
  /** Update the logical document size. */
  setDocumentSize(width: number, height: number): void;

  /** Return the current zoom factor. */
  getZoom(): number;
  /** Set the current zoom factor. */
  setZoom(zoom: number, opts?: { center?: { x: number; y: number }; clamp?: boolean }): void;
  /** Increase zoom by one step. */
  zoomIn(step?: number): void;
  /** Decrease zoom by one step. */
  zoomOut(step?: number): void;
  /** Reset zoom to the default document view. */
  resetZoom(): void;
  /** Reset zoom and pan to the captured home view. */
  resetView(): void;
  /** Pan the viewport by a delta. */
  panBy(dx: number, dy: number): void;
  /** Reset viewport panning. */
  resetPan(): void;
  /** Fit the document inside the viewport. */
  fitToViewport(padding?: number): void;

  /** Optional utilities namespace attached by higher-level integrations. */
  util?: any;
  /** Optional document-first namespace. */
  document?: any;
  /** Optional document actions namespace. */
  documentActions?: any;
  /** Optional runtime/renderer namespace. */
  runtime?: any;
  /** Optional multi-canvas helper. */
  getCanvases?: (mode?: 'toArray' | string) => any;

  /** Add a raster image from a URL. */
  addImageFromUrl: (url: string, opts?: any) => Promise<void>;
  /** Add a raster image from a File object. */
  addImageFromFile: (file: File, opts?: any) => Promise<void>;
  /** Add an SVG from a remote URL. */
  addSvgFromUrl: (url: string, opts?: any) => Promise<void>;
  /** Add an SVG from an inline SVG string. */
  addSvgFromString: (svgText: string, opts?: any) => Promise<void>;

  /** Export the current canvas or one explicit source. */
  download: (source?: DesignerExportSource, opts?: DesignerDownloadOptions) => Promise<void>;
  /** Render the active canvas or one stored view as a PNG blob. */
  exportPngBlob?: (opts?: { viewId?: string; scale?: number; quality?: number }) => Promise<Blob>;
  /** Export every registered view. */
  downloadAllViews?: (opts?: DesignerDownloadOptions) => Promise<void>;
  /** Export every drawing zone. */
  downloadAllDrawingZones?: (opts?: DesignerDownloadOptions) => Promise<void>;
  /** Export every exportable object. */
  downloadAllObjects?: (opts?: DesignerDownloadOptions) => Promise<void>;
  /** Return the objects considered exportable by the runtime. */
  getExportableObjects: () => any[];

  /** Curve the current active text object. */
  curveActiveText(opts?: { radius?: number; bend?: number; autoFit?: boolean; type?: any }): void;
  /** Update the current curved text object. */
  updateCurvedText(opts?: { radius?: number; bend?: number; autoFit?: boolean; type?: any; text?: string; baseline?: number }): void;
  /** Remove the curve effect from the current active text object. */
  uncurveActiveText(): void;

  /** Replace the full set of drawing zones. */
  setDrawingZones(zones: Array<{ id: string; x: number; y: number; width: number; height: number; physicalSize?: import('./physicalMeasurement').AsukaPhysicalSize }>, options?: { bypassPermissions?: boolean; source?: string }): void;
  /** Return the current drawing zones. */
  getDrawingZones(): Array<{ id: string; x: number; y: number; width: number; height: number; physicalSize?: import('./physicalMeasurement').AsukaPhysicalSize }>;
  /** Mark one drawing zone as active. */
  setActiveDrawingZone(id: string | null): void;
  /** Return the active drawing zone id. */
  getActiveDrawingZone(): string | null;
  /** Return every drawing area policy. */
  getDrawingAreaPolicies?: () => Record<string, DrawingAreaPolicy>;
  /** Return the runtime data associated with one drawing area. */
  getDrawingAreaRuntime?: (id: string) => DesignerDrawingAreaRuntime;
  /** Override the policy of one drawing area. */
  setDrawingAreaPolicy?: (id: string, policy: DrawingAreaPolicy) => void;
  /** Highlight drawing zones and wait for the user to pick one. */
  pickDrawingZoneForAdd?: () => Promise<string>;

  /** Move an object to a target layer index. */
  setLayerIndex(object: any, index: number): void;
  /** Return the current layer index of an object. */
  getLayerIndex(object: any): number | null;
  /** Normalize Fabric stacking order against Asuka layer rules. */
  normalizeLayers(): void;
  /** Reorder several objects at once. */
  reorderObjects?(objectIds: string[], opts?: { order?: 'canvas' | 'display' }): boolean;
  /** Preview one object on the canvas. */
  previewObject?(objectId: string | null, opts?: { mode?: 'fade-others' | 'outline'; dimOpacity?: number }): void;
  /** Clear the current object preview. */
  clearObjectPreview?(): void;
  /** Register one SVG asset in the shapes runtime. */
  addShapeSvgAsset?(payload: { id?: string; name: string; svgText: string }): string;
  /** Remove one SVG asset from the shapes runtime. */
  removeShapeSvgAsset?(id: string): void;
  /** Return the currently registered SVG shape assets. */
  getShapeSvgAssets?(): Array<{ id: string; name: string; svgText: string }>;
  /** Patch the default style applied to new shapes. */
  setShapeStyle?(patch: { fill?: string; stroke?: string; strokeWidth?: number; opacity?: number }): void;
  /** Patch the default shape editor settings. */
  setShapeDefaults?(patch: { rectRadius?: number; starSpikes?: number; starInnerRatio?: number }): void;
  /** Register a system font in the low-level font registry. */
  addSystemFont?(payload: string | AsukaSystemFontConfig): string;
  /** Register a Google Font in the low-level font registry. */
  addGoogleFont?(payload: string | AsukaGoogleFontConfig): Promise<string>;
  /** Register a self-hosted custom font in the low-level font registry. */
  addCustomFont?(payload: AsukaCustomFontConfig): Promise<string>;
  /** Remove a font from the low-level font registry. */
  removeFont?(id: string): void;
  /** Return the low-level font registry. */
  getFonts?(): AsukaRegisteredFont[];
  /** Load a font that is already registered. */
  loadFont?(id: string): Promise<boolean>;

  /** Assign an item group used by pricing and filtering helpers. */
  setItemGroup?: (object: any, itemGroup: 'Designs' | 'Images' | 'Text' | 'Tools' | 'Other') => void;
  /** Return objects filtered by item group. */
  getObjectsByItemGroup?: (itemGroup: 'Designs' | 'Images' | 'Text' | 'Tools' | 'Other', scope?: 'view' | 'document') => any[];

  /** Add a rectangle to the active canvas. */
  addRect(options?: DesignerRectOptions): any;
  /** Add a basic shape from a serializable renderer command. */
  addShape(options: DesignerShapeOptions): any;
  /** Create an editable drawing-area draft from serializable geometry. */
  createDrawingAreaDraft(options: DesignerDrawingAreaDraftOptions): Promise<any>;
  /** Convert one drawing-area draft into a structural drawing area. */
  commitDrawingAreaDraft(objectId: string): string | null;
  /** Start a one-shot pointer interaction that creates a drawing-area draft. */
  startDrawingAreaDraft(options: DesignerDrawingAreaDraftOptions, onComplete?: (object: any) => void): () => void;
  /** Add a text object to the active canvas. */
  addText(text: string, options?: DesignerTextOptions): any;
  /** Return every registered product element. */
  getElements: () => any[];
  /** Apply one element variation payload. */
  applyElementVariation?: (payload: AsukaElementVariationApplyInput) => Promise<boolean>;
  /** Register one new product element. */
  addElement: (name: string, element: any) => void;
  /** Patch one product element. */
  editElement: (name: string, patch: any) => void;
  /** Remove one product element. */
  removeElement: (name: string) => void;
  /** Add an element instance to the current view canvas using its active variation. */
  addElementInstance: (name: string, opts?: { variationId?: string; at?: { x: number; y: number }; props?: any; fitMode?: 'contain' | 'native' }) => Promise<void>;
  /** Apply one variation to all instances across the document. */
  applyElementVariationAcrossDocument: (name: string, variationId: string, sourceObjectId?: string, explicitPathNames?: string[], elementId?: string) => Promise<void>;
  /** Sync element instance flags across live and persisted instances. */
  syncElementInstancesToDocument: (name: string, patch: { bringToFront?: boolean; selectable?: boolean; evented?: boolean }) => Promise<void>;
  /** Apply one catalog edit to matching instances on the active view only. */
  syncElementEditToActiveView?: (identifier: string, element: any, options?: { variationId?: string; objectIds?: string[]; source?: string }) => Promise<void>;
  /** Apply one variation to a single object on the active canvas. */
  applyElementVariationToObject: (objectId: string, name: string, variationId: string) => Promise<void>;
  /** Clear the active canvas. */
  clear(): void;
  /** Undo the last canvas mutation and resolve after Fabric has restored it. */
  undo(): Promise<void>;
  /** Redo the next canvas mutation and resolve after Fabric has restored it. */
  redo(): Promise<void>;
  /** Reset the canvas to its initial state and resolve after restoration. */
  reset(): Promise<void>;
  /** Patch the low-level snapping configuration. */
  setSnapping(patch: any): void;
  /** Return the low-level snapping state. */
  getSnapping(): any;
  /** Return history cursor information. */
  getHistoryInfo(viewId?: string | null): { index: number; length: number; canUndo?: boolean; canRedo?: boolean; maxSnapshots?: number; engine?: string; scope?: string; viewId?: string | null };
  /** Return the aggregated drawing area pricing breakdown. */
  getDrawingAreaPriceUiBreakdown(id: string, externalPrice?: number | string): DesignerDrawingAreaPriceUiBreakdown;
  /** Return the aggregated drawing area price. */
  getDrawingAreaPrice(id: string, externalPrice?: number | string): number;
  /** Dispose the low-level runtime and Fabric resources. */
  dispose(): void;
}

/**
 * Ensure objects transform around their visual center without mutating their
 * geometric anchor (`originX` / `originY`).
 *
 * Changing an object origin after it has been created can be harmless for a
 * single object, but it becomes fragile once Fabric builds an `activeSelection`
 * from multiple members: one object can visually jump because its cached
 * coordinates and the temporary selection wrapper no longer agree perfectly.
 *
 * Fabric already supports centered transforms through `centeredRotation` and
 * `centeredScaling`, so we keep the original anchor untouched and only enable
 * those flags.
 */
function asukaEnsureCenterTransformOrigin(obj: any) {
  if (!obj) return;

  try {
    obj.set?.({
      centeredRotation: true,
      centeredScaling: true,
    });
    obj.setCoords?.();
  } catch {}
}

export interface DesignerInitOptions {
  backgroundColor?: string;

  /**
   * Instance identifier injected into emitted event metadata.
   * Assigned by createAsukaDesigner.
   */
  instanceId?: string;

  /**
   * Snapping configuration.
   * - threshold: snap distance in px (default: 6)
   * - release: release distance in px for hysteresis (default: threshold + 6)
   * - priority: candidate priority when multiple snaps are possible
   */
  snapConfig?: {
    threshold?: number;
    release?: number;
    priority?: 'zones-first' | 'objects-first';
  };
}

/**
 * Serialized Fabric canvas snapshot.
 */
export type DesignerSnapshot = any;

function makeObjectId(): string {

  // No need for a perfect UUID here, only a value that is unique enough for the current session.
  return `obj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}

function ensureObjectId(obj: any): string {
  if (!obj) return makeObjectId();
  if (!obj._asukaObjectId) {
    obj._asukaObjectId = makeObjectId();
  }
  return obj._asukaObjectId;
}

function resetCloneObjectIdentityDeep(obj: any) {
  if (!obj) return;
  try { delete obj._asukaObjectId; } catch {}
  try { delete obj.id; } catch {}
  try { delete obj.__uid; } catch {}
  try {
    const children = typeof obj.getObjects === 'function' ? obj.getObjects() : (Array.isArray(obj._objects) ? obj._objects : []);
    if (Array.isArray(children)) children.forEach((child: any) => resetCloneObjectIdentityDeep(child));
  } catch {}
}

function ensureObjectIdsDeep(obj: any) {
  if (!obj) return;
  try { ensureObjectId(obj); } catch {}
  try {
    const children = typeof obj.getObjects === 'function' ? obj.getObjects() : (Array.isArray(obj._objects) ? obj._objects : []);
    if (Array.isArray(children)) children.forEach((child: any) => ensureObjectIdsDeep(child));
  } catch {}
}

function getActiveObjectIds(canvas: any): string[] {
  try {
    const active = canvas?.getActiveObjects?.() ?? [];
    return active.map((o: any) => ensureObjectId(o));
  } catch {
    return [];
  }
}

/**
 * Wrapped Fabric engine.
 * - Global history support (undo / redo / reset) with history metadata
 * - Demo drawing zone support and movement constraints
 * - Simple snapping with visual snap indicators while moving objects
 * - Change notifications (onChange) for thumbnails and UI updates
 * - Snapshot export / import for per-view state management
 */

export function initDesignerCanvas(
  hostElement: HTMLElement,
  options: DesignerInitOptions = {},
) {
  if (typeof window === 'undefined') {
    throw new Error('initDesignerCanvas must be used in a browser environment.');
  }

  // Injected by createAsukaDesigner to tag all emitted events safely across multiple instances.
  const instanceId = options.instanceId ?? 'asuka-unknown';

  // Injected by createAsukaDesigner to tag all emitted events safely across multiple instances.

  // Injected by createAsukaDesigner to tag all emitted events safely across multiple instances.

  // Create and mount the <canvas> element
  const canvasElement = document.createElement('canvas');
  canvasElement.style.width = '100%';
  canvasElement.style.height = '100%';
  hostElement.innerHTML = '';
  hostElement.appendChild(canvasElement);

  const logicalWidth = hostElement.clientWidth || 800;
  const logicalHeight = hostElement.clientHeight || 600;
  // "Design" size (virtual coordinate space). We keep this constant and
  // scale the viewport to fit the host on resize, so objects keep their
  // coordinates regardless of the host's pixel size.
  const designWidth = logicalWidth;
  const designHeight = logicalHeight;

  // View/document size (max size). This can change when switching views.
  // It represents the "unscaled" document size in world units.
  let docWidth = designWidth;
  let docHeight = designHeight;
  canvasElement.width = logicalWidth;
  canvasElement.height = logicalHeight;

  const canvas = new fabric.Canvas(canvasElement, {
    backgroundColor: options.backgroundColor ?? '#ffffff',
    preserveObjectStacking: true,
  });
  const placementController = createAsukaPlacementSessionController<AsukaObjectTransform>('fabric');
  const emitFabricPlacement = (action: string, status: AsukaPlacementStatus | null) => {
    emitRenderEngineEvent({
      type: 'placement',
      engine: 'fabric',
      action,
      id: status?.objectId ?? null,
      payload: status,
    });
    return status;
  };

  const getCanvasPointer = (event: any, fromViewport = false) => {
    const modernMethod = fromViewport ? (canvas as any).getViewportPoint : (canvas as any).getScenePoint;
    if (typeof modernMethod === 'function') {
      const point = modernMethod.call(canvas, event);
      return { x: Number(point?.x) || 0, y: Number(point?.y) || 0 };
    }
    const point = (canvas as any).getPointer?.(event, fromViewport);
    return { x: Number(point?.x) || 0, y: Number(point?.y) || 0 };
  };

  try {
    const uiScaling = ((useAsukaStore.getState() as any)?.defaults?.uiScaling ?? DEFAULT_ASUKA_UI_SCALING) as any;
    (canvas as any).__asukaUiScaling = { ...DEFAULT_ASUKA_UI_SCALING, ...(uiScaling ?? {}) };
  } catch {
    try { (canvas as any).__asukaUiScaling = { ...DEFAULT_ASUKA_UI_SCALING }; } catch {}
  }


  const runtimeObjectIndex = new RuntimeObjectIndex({
    ensureObjectId,
    listRuntimeObjects: () => (canvas.getObjects?.() ?? []),
  });

  const trackRuntimeObject = (obj: any) => {
    runtimeObjectIndex.track(obj);
  };

  const untrackRuntimeObject = (obj: any) => {
    runtimeObjectIndex.untrack(obj);
  };

  const rebuildRuntimeObjectIndex = () => {
    runtimeObjectIndex.rebuild();
  };

  const getRuntimeObjectByIdFast = (objectId: string | null | undefined) => runtimeObjectIndex.get(objectId);

  const getRuntimeObjectsByIds = (ids: string[] | null | undefined): any[] => runtimeObjectIndex.getMany(ids);

  const objectPreviewState = new Map<string, { opacity?: number; shadow?: any }>();
  let previewedObjectId: string | null = null;

  const clearObjectPreviewState = () => {
    if (!objectPreviewState.size) {
      previewedObjectId = null;
      return;
    }
    try {
      for (const obj of canvas.getObjects?.() ?? []) {
        const objectId = String((obj as any)?._asukaObjectId ?? '').trim();
        if (!objectId || !objectPreviewState.has(objectId)) continue;
        const saved = objectPreviewState.get(objectId) ?? {};
        try {
          if (saved.opacity !== undefined) (obj as any).set?.('opacity', saved.opacity);
          else (obj as any).set?.('opacity', 1);
        } catch {}
        try {
          if (saved.shadow !== undefined) (obj as any).set?.('shadow', saved.shadow ?? null);
          else (obj as any).set?.('shadow', null);
        } catch {}
        try { (obj as any).dirty = true; } catch {}
      }
    } catch {}
    objectPreviewState.clear();
    previewedObjectId = null;
    try { canvas.requestRenderAll?.(); } catch {}
  };

  const previewObjectOnCanvas = (objectId: string | null, opts?: { mode?: 'fade-others' | 'outline'; dimOpacity?: number }) => {
    const nextId = String(objectId ?? '').trim();
    if (!nextId) {
      clearObjectPreviewState();
      return;
    }
    if (previewedObjectId === nextId) return;
    clearObjectPreviewState();

    const dimOpacity = Math.max(0.08, Math.min(1, Number(opts?.dimOpacity ?? 0.18) || 0.18));
    const mode = opts?.mode === 'outline' ? 'outline' : 'fade-others';
    const target = getRuntimeObjectByIdFast(nextId);
    if (!target) return;

    const runtimeObjects = (canvas.getObjects?.() ?? []).filter((obj: any) => isUserLayerRuntimeObject(obj));
    for (const obj of runtimeObjects) {
      const id = String((obj as any)?._asukaObjectId ?? '').trim();
      if (!id) continue;
      objectPreviewState.set(id, {
        opacity: typeof (obj as any).opacity === 'number' ? Number((obj as any).opacity) : 1,
        shadow: (obj as any).shadow ?? null,
      });

      if (id === nextId) {
        try { (obj as any).set?.('opacity', 1); } catch {}
        try {
          (obj as any).set?.('shadow', {
            color: 'rgba(37, 99, 235, 0.35)',
            blur: 18,
            offsetX: 0,
            offsetY: 0,
          });
        } catch {}
      } else if (mode === 'fade-others') {
        try { (obj as any).set?.('opacity', dimOpacity); } catch {}
        try { (obj as any).set?.('shadow', null); } catch {}
      }
      try { (obj as any).dirty = true; } catch {}
    }

    previewedObjectId = nextId;
    try { canvas.requestRenderAll?.(); } catch {}
  };

  try {
    canvas.on?.('object:added', (e: any) => {
      try { trackRuntimeObject(e?.target); } catch {}
    });
    canvas.on?.('object:removed', (e: any) => {
      try { untrackRuntimeObject(e?.target); } catch {}
    });
  } catch {}

  const syncVisualUiScale = () => {
    try { syncCanvasUiScaling(canvas, (canvas as any).__asukaUiScaling ?? null); } catch {}
  };
  try {
    canvas.on?.('before:render', syncVisualUiScale);
  } catch {}


  // ---------------------------------------------------------------------------
  // Responsive canvas helper
  // ---------------------------------------------------------------------------
  // IMPORTANT:
  // - Fabric zoom must represent the USER zoom only.
  // - The document keeps its own logical size (docWidth/docHeight).
  // - When the host is smaller than the document, we resize the DOM canvas with
  //   CSS only instead of changing Fabric's viewport scale.
  // This preserves the existing snapping system because world coordinates and
  // Fabric's internal size stay aligned with the document.
  const fitViewportToHost = () => {
    const rect = hostElement.getBoundingClientRect();
    const availW = Math.max(1, Math.floor(rect.width || hostElement.clientWidth || 1));
    const availH = Math.max(1, Math.floor(rect.height || hostElement.clientHeight || 1));
    const docW = Math.max(1, Math.floor(docWidth));
    const docH = Math.max(1, Math.floor(docHeight));
    const cssScale = Math.min(1, availW / docW, availH / docH);
    const cssW = Math.max(1, Math.round(docW * cssScale));
    const cssH = Math.max(1, Math.round(docH * cssScale));

    // Fabric clears both backing canvases whenever setDimensions() is called,
    // even when the intrinsic size is unchanged. Responsive layout changes
    // (opening a drawer, selecting an object, resizing the host) happen often,
    // so only touch the backing store when the logical document really changed.
    const currentW = Math.max(1, Math.floor(Number(canvas.getWidth?.() ?? canvasElement.width ?? 1) || 1));
    const currentH = Math.max(1, Math.floor(Number(canvas.getHeight?.() ?? canvasElement.height ?? 1) || 1));
    const intrinsicSizeChanged = currentW !== docW || currentH !== docH;
    if (intrinsicSizeChanged) {
      try {
        canvas.setDimensions({ width: docW, height: docH }, { cssOnly: false });
      } catch {
        try {
          canvasElement.width = docW;
          canvasElement.height = docH;
        } catch {}
      }
    }

    // CSS responsivity only: shrink the rendered canvas when the host is smaller.
    const wrapper = (canvas as any).wrapperEl as HTMLElement | undefined;
    const lower = canvas.getElement?.() as HTMLCanvasElement | undefined;
    const upper = (canvas as any).upperCanvasEl as HTMLCanvasElement | undefined;

    hostElement.style.overflow = 'hidden';
    hostElement.style.display = 'flex';
    hostElement.style.alignItems = 'center';
    hostElement.style.justifyContent = 'center';

    const applyCssSize = (el?: HTMLElement | null) => {
      if (!el) return;
      el.style.width = `${cssW}px`;
      el.style.height = `${cssH}px`;
      el.style.maxWidth = '100%';
      el.style.maxHeight = '100%';
    };

    applyCssSize(wrapper);
    applyCssSize(lower);
    applyCssSize(upper);

    // Keep the initial viewport at a true Fabric zoom of 100%.
    try {
      const vpt = canvas.viewportTransform ? [...canvas.viewportTransform] : [1, 0, 0, 1, 0, 0];
      const zoom = Number(vpt[0] || 1) || 1;
      vpt[0] = zoom;
      vpt[3] = zoom;
      vpt[4] = 0;
      vpt[5] = 0;
      canvas.setViewportTransform(vpt as any);
    } catch {}

    try { canvas.calcOffset(); } catch {}
    try { syncDrawingZoneVisuals(); } catch {}
    try { syncVisualUiScale(); } catch {}
    // A backing-store resize clears synchronously. Paint synchronously as well
    // so the browser never presents the cleared frame before Fabric's RAF.
    if (intrinsicSizeChanged) {
      try { canvas.renderAll?.(); } catch {}
    } else {
      try { canvas.requestRenderAll?.(); } catch {}
    }
  };

  // Initial layout.
  fitViewportToHost();

  function setCanvasFixedSizeImpl(width: number, height: number) {
    const w = Math.max(1, Math.floor(Number(width) || 1));
    const h = Math.max(1, Math.floor(Number(height) || 1));
    try { canvas.setDimensions({ width: w, height: h }, { cssOnly: false }); } catch {}
    try { canvasElement.width = w; canvasElement.height = h; } catch {}
    const wrapper = (canvas as any).wrapperEl as HTMLElement | undefined;
    const lower = canvas.getElement?.() as HTMLCanvasElement | undefined;
    const upper = (canvas as any).upperCanvasEl as HTMLCanvasElement | undefined;
    const applyCssSize = (el?: HTMLElement | null) => {
      if (!el) return;
      el.style.width = `${w}px`;
      el.style.height = `${h}px`;
      el.style.maxWidth = '100%';
      el.style.maxHeight = '100%';
    };
    applyCssSize(wrapper); applyCssSize(lower); applyCssSize(upper);
    try { canvas.calcOffset?.(); } catch {}
    try { syncVisualUiScale(); } catch {}
    try { canvas.requestRenderAll?.(); } catch {}
  }

  function setCanvasFitToHostImpl() {
    fitViewportToHost();
  }

  const createStarPoints = (cx: number, cy: number, spikes: number, outerR: number, innerR: number) => {
    const pts: Array<{ x: number; y: number }> = [];
    const count = Math.max(2, Math.floor(spikes || 0));
    for (let i = 0; i < count * 2; i++) {
      const r = i % 2 === 0 ? outerR : innerR;
      const a = -Math.PI / 2 + (i * Math.PI) / count;
      pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
    }
    return pts;
  };

  // ---------------------------------------------------------------------------
  // Fabric serialization patch
  // ---------------------------------------------------------------------------
  // We rely heavily on custom properties for Elements (e.g. _asukaElementName).
  // Some Fabric builds can drop unknown props unless they are explicitly added
  // to the object's `toObject()` output. Undo/redo + view snapshots depend on
  // these props being round-trippable.
  if (!__ASUKA_TO_OBJECT_PATCHED__) {
    __ASUKA_TO_OBJECT_PATCHED__ = true;
    try {
      const ObjCtor = (fabric as any).Object;
      const proto = ObjCtor?.prototype;
      if (proto && typeof proto.toObject === 'function') {
        const orig = proto.toObject;
        proto.toObject = function (propertiesToInclude?: string[]) {
          const base = orig.call(this, propertiesToInclude);
          const self: any = this;
          const extra = {
            // stable ids / metadata
            _asukaObjectId: self._asukaObjectId,
            id: self.id,
            objectId: self.objectId,
            asukaId: self.asukaId,
            // roles / zones
            _asukaRole: self._asukaRole,
            _asukaZoneId: self._asukaZoneId,
            _asukaLastGood: self._asukaLastGood,
            // Elements
            _asukaElementIsInstance: self._asukaElementIsInstance,
            _asukaElementId: self._asukaElementId,
            _asukaElementFamilyId: self._asukaElementFamilyId,
            _asukaElementName: self._asukaElementName,
            _asukaElementVariationId: self._asukaElementVariationId,
            [FABRIC_VARIATION_FRAME_PROPERTY]: self[FABRIC_VARIATION_FRAME_PROPERTY],
            // Fixed slots / markers
            _asukaFixedSlotId: self._asukaFixedSlotId,
            _asukaFixedSlotType: self._asukaFixedSlotType,
            _asukaFixedSlotConstraints: self._asukaFixedSlotConstraints,
            _asukaFixedConstraints: self._asukaFixedConstraints,
            _asukaFixedSlotIsIndicator: self._asukaFixedSlotIsIndicator,
            _asukaFixedSlotIsContent: self._asukaFixedSlotIsContent,
            __asukaFixedLockedCenterX: self.__asukaFixedLockedCenterX,
            __asukaFixedLockedCenterY: self.__asukaFixedLockedCenterY,
            __asukaFixedLockedAngle: self.__asukaFixedLockedAngle,
            // grouping / pricing
            itemGroup: self.itemGroup,
            layerIndex: self.layerIndex,
            price: self.price,
            printingMethodId: self.printingMethodId,
            // interaction flags
            selectable: self.selectable,
            evented: self.evented,
            hasControls: self.hasControls,
            hasBorders: self.hasBorders,
            hoverCursor: self.hoverCursor,
            perPixelTargetFind: self.perPixelTargetFind,
            targetFindTolerance: self.targetFindTolerance,
            excludeFromExport: self.excludeFromExport,
            // font persistence / recovery
            _asukaFontId: self._asukaFontId,
            _asukaFontSource: self._asukaFontSource,
            _asukaFontCssUrl: self._asukaFontCssUrl,
            _asukaFontUrl: self._asukaFontUrl,
            _asukaFontFormat: self._asukaFontFormat,
            _asukaFontWeight: self._asukaFontWeight,
            _asukaFontStyle: self._asukaFontStyle,
          };
          // Only attach defined values to keep JSON clean.
          for (const k of Object.keys(extra)) {
            if ((extra as any)[k] !== undefined) (base as any)[k] = (extra as any)[k];
          }
          return base;
        };
      }
    } catch {
      // best-effort
    }
  }


  // ---------------------------------------------------------------------------
  // Viewport controls (zoom + pan)
  // ---------------------------------------------------------------------------

  // NOTE: rulers/overlays read viewportTransform, so we keep it as source of truth.
  let __isPanning = false;
  let __lastClientX = 0;
  let __lastClientY = 0;
  let __spaceDown = false;

  // "Home" view (captured after init, and can be used for a stable reset)
  let __homeViewportTransform: number[] | null = null;
  const __captureHomeView = () => {
    try {
      const vpt = canvas.viewportTransform;
      __homeViewportTransform = vpt ? [...vpt] : [1, 0, 0, 1, 0, 0];
    } catch {
      __homeViewportTransform = [1, 0, 0, 1, 0, 0];
    }
  };

  const __onKeyDown = (e: KeyboardEvent) => {
    if (e.code === 'Space') {
      __spaceDown = true;
      try {
        canvas.defaultCursor = 'grab';
      } catch {}
    }

    // Shortcuts (like Figma)
    if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === '+' || e.key === '=')) {
      e.preventDefault();
      instance.zoomIn(0.1);
    }
    if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_')) {
      e.preventDefault();
      instance.zoomOut(0.1);
    }
    if ((e.ctrlKey || e.metaKey) && (e.key === '0')) {
      e.preventDefault();
      instance.resetView();
    }
  };

  const __onKeyUp = (e: KeyboardEvent) => {
    if (e.code === 'Space') {
      __spaceDown = false;
      try {
        canvas.defaultCursor = 'default';
      } catch {}
    }
  };

  window.addEventListener('keydown', __onKeyDown, { passive: false } as any);
  window.addEventListener('keyup', __onKeyUp, { passive: true } as any);

  canvas.on('mouse:wheel', (opt: any) => {
    const e = opt?.e as WheelEvent | undefined;
    if (!e) return;

    // Once the pointer is over the workspace, the wheel belongs to the
    // designer. This prevents the surrounding product/admin page from moving
    // while the user is manipulating the canvas.
    e.preventDefault();
    e.stopPropagation();

    // Canvas zoom is enabled with Ctrl/Cmd + wheel. A plain wheel is consumed
    // without moving the host page; panning stays available through Space or
    // the middle mouse button.
    const shouldZoomCanvas = e.ctrlKey || e.metaKey;
    if (!shouldZoomCanvas) return;

    const delta = e.deltaY;
    let zoom = canvas.getZoom?.() ?? 1;

    // Smooth exponential scaling
    zoom *= Math.pow(0.999, delta);
    zoom = Math.min(8, Math.max(0.1, zoom));

    const centerPoint = { x: (canvas.getWidth?.() ?? (canvas as any).width ?? 0) / 2, y: (canvas.getHeight?.() ?? (canvas as any).height ?? 0) / 2 };
    const pointer = canvas.getPointer?.(e as any) ?? { x: (e as any).offsetX ?? centerPoint.x, y: (e as any).offsetY ?? centerPoint.y };
    const point = e.altKey ? pointer : centerPoint;
    try {
      canvas.zoomToPoint(new fabric.Point(point.x, point.y), zoom);
      canvas.requestRenderAll?.();
      notifyChange();
    } catch {
      // ignore
    }
  });

  canvas.on('mouse:down', (opt: any) => {
    const e = opt?.e as MouseEvent | undefined;
    if (!e) return;

    const isMiddle = e.button === 1;
    if (__spaceDown || isMiddle) {
      __isPanning = true;
      __lastClientX = e.clientX;
      __lastClientY = e.clientY;
      try {
        canvas.selection = false;
        canvas.defaultCursor = 'grabbing';
      } catch {}
    }
  });

  canvas.on('mouse:move', (opt: any) => {
    if (!__isPanning) return;
    const e = opt?.e as MouseEvent | undefined;
    if (!e) return;

    const dx = e.clientX - __lastClientX;
    const dy = e.clientY - __lastClientY;
    __lastClientX = e.clientX;
    __lastClientY = e.clientY;

    instance.panBy(dx, dy);
  });

  canvas.on('mouse:up', () => {
    if (__isPanning) {
      __isPanning = false;
      try {
        canvas.selection = true;
        canvas.defaultCursor = __spaceDown ? 'grab' : 'default';
      } catch {}
    }
  });

// PERF: lock to prevent UI/store/event spam during live transforms.
  // Must be declared before event handlers.
  let isLiveTransform = false;

  // ---------------------------------------------------------------------------
  // Listener toggles (behaviour flags)
  // ---------------------------------------------------------------------------
  const isListenerEnabled = (name: string): boolean => {
    try {
      const s: any = useAsukaStore.getState();
      if (typeof s?.isListenerEnabled === 'function') return !!s.isListenerEnabled(name);
      const v = (s?.listenersEnabled ?? {})[name];
      return v !== false;
    } catch {
      return true;
    }
  };

  // ---------------------------------------------------------------------------
  // Fixed slots (placeholders)
  // ---------------------------------------------------------------------------

  type FixedSlotType = 'text' | 'image' | 'design';
  type FixedSlotConstraints = {
    lockX?: boolean;
    lockY?: boolean;
    lockRotation?: boolean;
    minWidth?: number;
    minHeight?: number;
    maxWidth?: number;
    maxHeight?: number;
    minChars?: number;
    maxChars?: number;
    maxLines?: number;
    allowedFonts?: string[];
    allowedColors?: string[];
  };

  type FixedSlotTargetSize = { targetWidth?: number; targetHeight?: number };

  const normalizePositiveNumber = (value: any): number | undefined => {
    const n = Number(value);
    return Number.isFinite(n) && n > 0 ? n : undefined;
  };

  const getFixedSlotTargetSize = (slot?: any): FixedSlotTargetSize => ({
    targetWidth: normalizePositiveNumber(slot?.targetWidth ?? slot?.objectWidth ?? slot?.width),
    targetHeight: normalizePositiveNumber(slot?.targetHeight ?? slot?.objectHeight ?? slot?.height),
  });

  const getFixedSlotsState = () => {
    const s: any = useAsukaStore.getState();
    return {
      placing: s.fixedSlotsPlacing as any,
      slots: (s.fixedSlots ?? []) as any[],
      activeId: (s.fixedSlotActiveId ?? null) as string | null,
    };
  };

  // ---------------------------------------------------------------------------
  // Shapes placement (basic + custom SVG)
  // ---------------------------------------------------------------------------

  const getShapesState = () => {
    const s: any = useAsukaStore.getState();
    return {
      placing: (s.shapePlacing ?? null) as any,
      svgAssets: (s.shapeSvgAssets ?? []) as any[],
      style: (s.shapeStyle ?? { fill: 'transparent', stroke: '#111827', strokeWidth: 2, opacity: 1 }) as any,
      editDefaults: (s.shapeEditDefaults ?? { rectRadius: 0, starSpikes: 5, starInnerRatio: 0.45 }) as any,
    };
  };

  let __shapePlacing = false;
  let __shapeStart: { x: number; y: number } | null = null;
  let __shapePreview: any = null;
  let __shapePayload: any = null;

  const clearShapePreview = () => {
    if (__shapePreview) {
      try { canvas.remove(__shapePreview); } catch {}
      __shapePreview = null;
    }
  };

  const beginShapePlacement = (payload: any, e: MouseEvent) => {
    try {
      const p = getCanvasPointer(e, true);
      __shapePlacing = true;
      __shapePayload = payload;
      __shapeStart = { x: p.x, y: p.y };

      // Preview: dashed rectangle (always, even for basic shapes)
      __shapePreview = new fabric.Rect({
        left: p.x,
        top: p.y,
        width: 1,
        height: 1,
        fill: 'transparent',
        stroke: '#111827',
        strokeWidth: 1,
        strokeDashArray: [6, 6],
        originX: 'left',
        originY: 'top',
        selectable: false,
        evented: false,
      });
      ( __shapePreview as any)._asukaRole = 'shapePreview';
      canvas.add(__shapePreview);
      canvas.requestRenderAll?.();
    } catch {
      // ignore
    }
  };

  const updateShapePreview = (e: MouseEvent) => {
    if (!__shapePlacing || !__shapeStart || !__shapePreview) return;
    try {
      const p = getCanvasPointer(e, true);
      const x0 = __shapeStart.x;
      const y0 = __shapeStart.y;
      const left = Math.min(x0, p.x);
      const top = Math.min(y0, p.y);
      const w = Math.max(1, Math.abs(p.x - x0));
      const h = Math.max(1, Math.abs(p.y - y0));
      __shapePreview.set?.({ left, top, width: w, height: h });
      __shapePreview.setCoords?.();
      canvas.requestRenderAll?.();
    } catch {
      // ignore
    }
  };

  const finalizeShapePlacement = async () => {
    if (!__shapePlacing || !__shapeStart || !__shapePreview) return;
    const payload = __shapePayload;
    const rect = __shapePreview.getBoundingRect?.(true, true) ?? { left: __shapePreview.left ?? 0, top: __shapePreview.top ?? 0, width: __shapePreview.width ?? 1, height: __shapePreview.height ?? 1 };

    clearShapePreview();
    __shapePlacing = false;
    __shapeStart = null;
    __shapePayload = null;

    // One-shot placement
    try { useAsukaStore.getState().cancelPlaceShape?.(); } catch {}

    const left = rect.left ?? 0;
    const top = rect.top ?? 0;
    const width = Math.max(1, rect.width ?? 1);
    const height = Math.max(1, rect.height ?? 1);
    const cx = left + width / 2;
    const cy = top + height / 2;

    const { style: shStyle, editDefaults: shEdit } = getShapesState();
    const fill = typeof shStyle?.fill === 'string' ? shStyle.fill : 'transparent';
    const stroke = typeof shStyle?.stroke === 'string' ? shStyle.stroke : '#111827';
    const strokeWidth = Math.max(0, Number(shStyle?.strokeWidth ?? 2) || 2);
    const opacity = Math.min(1, Math.max(0, Number(shStyle?.opacity ?? 1) || 1));

    try {
      let obj: any = null;

      if (payload?.type === 'basic') {
        const kind = payload?.kind;
        if (kind === 'rect') {
          const r = Math.max(0, Number(shEdit?.rectRadius ?? 0) || 0);
          obj = new fabric.Rect({ left: cx, top: cy, width, height, rx: r, ry: r, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
          (obj as any)._asukaShapeKind = 'rect';
          (obj as any)._asukaRectRadius = r;
        } else if (kind === 'circle') {
          obj = new fabric.Ellipse({ left: cx, top: cy, rx: width / 2, ry: height / 2, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
          (obj as any)._asukaShapeKind = 'circle';
        } else if (kind === 'triangle') {
          obj = new fabric.Triangle({ left: cx, top: cy, width, height, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
          (obj as any)._asukaShapeKind = 'triangle';
        } else if (kind === 'line') {
          const lw = strokeWidth > 0 ? strokeWidth : Math.max(2, Math.round(Math.min(width, height) / 18));
          obj = new fabric.Line([left, cy, left + width, cy], { stroke, strokeWidth: lw, opacity, originX: 'center', originY: 'center' });
          (obj as any)._asukaShapeKind = 'line';
        } else if (kind === 'star') {
          const pts: Array<{ x: number; y: number }> = [];
          const spikes = Math.max(3, Math.min(32, Number(shEdit?.starSpikes ?? 5) || 5));
          const outerR = Math.min(width, height) / 2;
          const innerRatio = Math.min(0.95, Math.max(0.05, Number(shEdit?.starInnerRatio ?? 0.45) || 0.45));
          const innerR = outerR * innerRatio;
          let rot = (Math.PI / 2) * 3;
          const step = Math.PI / spikes;
          for (let i = 0; i < spikes; i++) {
            pts.push({ x: Math.cos(rot) * outerR, y: Math.sin(rot) * outerR });
            rot += step;
            pts.push({ x: Math.cos(rot) * innerR, y: Math.sin(rot) * innerR });
            rot += step;
          }
          obj = new fabric.Polygon(pts, { left: cx, top: cy, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
          (obj as any)._asukaShapeKind = 'star';
          (obj as any)._asukaStarSpikes = spikes;
          (obj as any)._asukaStarInnerRatio = innerRatio;
        }

        if (obj) {
          try { (instance as any)?._asukaPrepareExternalObject?.(obj); } catch {}
          (obj as any).itemGroup = (obj as any).itemGroup ?? 'Other';
          canvas.add(obj);
          canvas.setActiveObject?.(obj);
          canvas.requestRenderAll?.();
          try { notifyChange(); } catch {}
        }
        return;
      }

      if (payload?.type === 'svg') {
        const { svgAssets } = getShapesState();
        const asset = (svgAssets ?? []).find((a: any) => String(a?.id) === String(payload.svgId)) ?? null;
        const svgText = asset?.svgText;
        if (!svgText) return;

        await instance.addSvgFromString(svgText, { left: cx, top: cy, center: false, itemGroup: 'Other' });

        // Scale to drawn rect (keep aspect ratio)
        try {
          const added: any = canvas.getActiveObject?.();
          if (added) {
            // Apply default style to svg objects when possible (paths inside groups)
            const applyStyle = (o: any) => {
              if (!o) return;
              try { o.set?.({ opacity }); } catch {}
              // Prefer stroke over fill for outlines if provided
              try {
                if (typeof fill === 'string') o.set?.({ fill });
              } catch {}
              try {
                if (typeof stroke === 'string') o.set?.({ stroke });
              } catch {}
              try {
                if (Number.isFinite(strokeWidth)) o.set?.({ strokeWidth });
              } catch {}
              try {
                const objs = o.getObjects?.();
                if (Array.isArray(objs)) objs.forEach(applyStyle);
              } catch {}
            };
            applyStyle(added);

            try {
              (added as any)._asukaShapeKind = 'svg';
              (added as any)._asukaSvgAssetId = String(payload.svgId ?? '');
            } catch {}

            added.set?.({ originX: 'center', originY: 'center', left: cx, top: cy });
            const b = added.getBoundingRect?.(true, true) ?? { width: 1, height: 1 };
            const sx = width / Math.max(1, b.width ?? 1);
            const sy = height / Math.max(1, b.height ?? 1);
            const s = Math.min(sx, sy);
            added.set?.({ scaleX: (added.scaleX ?? 1) * s, scaleY: (added.scaleY ?? 1) * s });
            added.setCoords?.();
            canvas.requestRenderAll?.();
            try { notifyChange(); } catch {}
          }
        } catch {}
      }
    } catch {
      // ignore
    }
  };

  const findSlotById = (id: string | null | undefined) => {
    if (!id) return null;
    const { slots } = getFixedSlotsState();
    return (slots ?? []).find((x: any) => String(x?.id) === String(id)) ?? null;
  };

  const normalizeFixedSlotConstraints = (constraints?: FixedSlotConstraints | null): FixedSlotConstraints => {
    if (!constraints || typeof constraints !== 'object') return {};

    const toNumber = (value: any) => {
      const n = Number(value);
      return Number.isFinite(n) ? n : undefined;
    };

    const allowedFonts = Array.isArray(constraints.allowedFonts)
      ? constraints.allowedFonts.map((value) => String(value).trim()).filter(Boolean)
      : undefined;

    const allowedColors = Array.isArray(constraints.allowedColors)
      ? constraints.allowedColors.map((value) => String(value).trim()).filter(Boolean)
      : undefined;

    const minWidth = toNumber(constraints.minWidth);
    const minHeight = toNumber(constraints.minHeight);
    const maxWidth = toNumber(constraints.maxWidth);
    const maxHeight = toNumber(constraints.maxHeight);
    const minChars = toNumber(constraints.minChars);
    const maxChars = toNumber(constraints.maxChars);
    const maxLines = toNumber(constraints.maxLines);

    return {
      lockX: constraints.lockX === true,
      lockY: constraints.lockY === true,
      lockRotation: constraints.lockRotation === true,
      minWidth: typeof minWidth === 'number' && minWidth > 0 ? minWidth : undefined,
      minHeight: typeof minHeight === 'number' && minHeight > 0 ? minHeight : undefined,
      maxWidth: typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined,
      maxHeight: typeof maxHeight === 'number' && maxHeight > 0 ? maxHeight : undefined,
      minChars: typeof minChars === 'number' && minChars >= 0 ? Math.floor(minChars) : undefined,
      maxChars: typeof maxChars === 'number' && maxChars >= 0 ? Math.floor(maxChars) : undefined,
      maxLines: typeof maxLines === 'number' && maxLines >= 1 ? Math.floor(maxLines) : undefined,
      allowedFonts: allowedFonts?.length ? allowedFonts : undefined,
      allowedColors: allowedColors?.length ? allowedColors : undefined,
    };
  };

  const walkFixedConstraintTargets = (obj: any, visitor: (target: any) => void) => {
    const visited = new Set<any>();

    const visit = (target: any) => {
      if (!target || visited.has(target)) return;
      visited.add(target);
      visitor(target);

      try {
        const children = target.getObjects?.() ?? target._objects ?? [];
        if (Array.isArray(children)) {
          children.forEach(visit);
        }
      } catch {
        // ignore nested traversal failures
      }
    };

    visit(obj);
  };

  const clampFixedConstraintScale = (obj: any, constraints?: FixedSlotConstraints) => {
    if (!obj || !constraints) return;
    const width = Math.max(1, Math.abs(Number(obj.width) || 1));
    const height = Math.max(1, Math.abs(Number(obj.height) || 1));

    let scaleX = Number(obj.scaleX);
    let scaleY = Number(obj.scaleY);
    if (!Number.isFinite(scaleX) || scaleX === 0) scaleX = 1;
    if (!Number.isFinite(scaleY) || scaleY === 0) scaleY = 1;

    const signX = scaleX < 0 ? -1 : 1;
    const signY = scaleY < 0 ? -1 : 1;
    let nextScaleX = Math.abs(scaleX);
    let nextScaleY = Math.abs(scaleY);

    if (typeof constraints.minWidth === 'number') nextScaleX = Math.max(nextScaleX, constraints.minWidth / width);
    if (typeof constraints.maxWidth === 'number') nextScaleX = Math.min(nextScaleX, constraints.maxWidth / width);
    if (typeof constraints.minHeight === 'number') nextScaleY = Math.max(nextScaleY, constraints.minHeight / height);
    if (typeof constraints.maxHeight === 'number') nextScaleY = Math.min(nextScaleY, constraints.maxHeight / height);

    const currentCenter = (() => {
      try { return obj.getCenterPoint?.(); } catch { return null; }
    })();

    obj.set?.({
      scaleX: signX * nextScaleX,
      scaleY: signY * nextScaleY,
    });

    if (currentCenter) {
      try { obj.setPositionByOrigin?.(currentCenter, 'center', 'center'); } catch {}
    }
    try { obj.setCoords?.(); } catch {}
  };

  const applyFixedSlotTargetSizeToObject = (obj: any, slot?: any) => {
    if (!obj || !slot) return;
    const { targetWidth, targetHeight } = getFixedSlotTargetSize(slot);
    if (typeof targetWidth !== 'number' && typeof targetHeight !== 'number') return;

    const currentCenter = (() => {
      try { return obj.getCenterPoint?.(); } catch { return null; }
    })();

    const isTextbox = isFabricTextboxObject(obj);
    try {
      if (isTextbox && typeof targetWidth === 'number') {
        obj.set?.({ width: targetWidth, scaleX: Number(obj.scaleX) < 0 ? -1 : 1 });
      }
    } catch {}

    const baseWidth = Math.max(1, Math.abs(Number(obj.width) || 1));
    const baseHeight = Math.max(1, Math.abs(Number(obj.height) || 1));
    let scaleX = Number(obj.scaleX);
    let scaleY = Number(obj.scaleY);
    if (!Number.isFinite(scaleX) || scaleX === 0) scaleX = 1;
    if (!Number.isFinite(scaleY) || scaleY === 0) scaleY = 1;

    const signX = scaleX < 0 ? -1 : 1;
    const signY = scaleY < 0 ? -1 : 1;
    const nextScaleX = typeof targetWidth === 'number' && !isTextbox ? signX * (targetWidth / baseWidth) : scaleX;
    const nextScaleY = typeof targetHeight === 'number' ? signY * (targetHeight / baseHeight) : scaleY;

    try {
      obj.set?.({ scaleX: nextScaleX, scaleY: nextScaleY });
      (obj as any)._asukaFixedSlotTargetWidth = targetWidth;
      (obj as any)._asukaFixedSlotTargetHeight = targetHeight;
      if (currentCenter) obj.setPositionByOrigin?.(currentCenter, 'center', 'center');
      obj.setCoords?.();
    } catch {}
  };

  const getFixedObjectCenter = (obj: any) => {
    if (!obj) return { x: 0, y: 0 };
    try {
      const center = obj.getCenterPoint?.();
      const x = Number(center?.x);
      const y = Number(center?.y);
      if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
    } catch {}
    return {
      x: Number(obj?.left ?? 0) || 0,
      y: Number(obj?.top ?? 0) || 0,
    };
  };

  const setFixedObjectCenter = (obj: any, center: { x: number; y: number }) => {
    if (!obj || !center) return;
    const x = Number(center.x);
    const y = Number(center.y);
    if (!Number.isFinite(x) || !Number.isFinite(y)) return;
    try {
      obj.setPositionByOrigin?.({ x, y }, 'center', 'center');
    } catch {
      try { obj.set?.({ originX: 'center', originY: 'center', left: x, top: y }); } catch {}
    }
    try { obj.setCoords?.(); } catch {}
  };

  const resolveFixedConstraintAnchor = (obj: any) => {
    const slotId = String((obj as any)?._asukaFixedSlotId ?? '').trim();
    const slot = slotId ? findSlotById(slotId) : null;
    if (slot) {
      const x = Number(slot.x ?? NaN);
      const y = Number(slot.y ?? NaN);
      if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
    }

    const lockedX = Number((obj as any)?.__asukaFixedLockedCenterX);
    const lockedY = Number((obj as any)?.__asukaFixedLockedCenterY);
    const current = getFixedObjectCenter(obj);
    return {
      x: Number.isFinite(lockedX) ? lockedX : current.x,
      y: Number.isFinite(lockedY) ? lockedY : current.y,
    };
  };

  const enforceFixedConstraintPosition = (obj: any, constraints?: FixedSlotConstraints) => {
    if (!obj) return;
    const normalized = normalizeFixedSlotConstraints(constraints ?? (obj as any)?._asukaFixedSlotConstraints ?? (obj as any)?._asukaFixedConstraints ?? {});
    if (!normalized.lockX && !normalized.lockY) return;

    const anchor = resolveFixedConstraintAnchor(obj);
    const current = getFixedObjectCenter(obj);
    const next = {
      x: normalized.lockX ? Number((obj as any).__asukaFixedLockedCenterX) : current.x,
      y: normalized.lockY ? Number((obj as any).__asukaFixedLockedCenterY) : current.y,
    };

    if (normalized.lockX && !Number.isFinite(next.x)) next.x = anchor.x;
    if (normalized.lockY && !Number.isFinite(next.y)) next.y = anchor.y;
    if (normalized.lockX) (obj as any).__asukaFixedLockedCenterX = next.x;
    if (normalized.lockY) (obj as any).__asukaFixedLockedCenterY = next.y;

    if (
      (normalized.lockX && Math.abs(Number(current.x ?? 0) - Number(next.x ?? 0)) > 0.01) ||
      (normalized.lockY && Math.abs(Number(current.y ?? 0) - Number(next.y ?? 0)) > 0.01)
    ) {
      setFixedObjectCenter(obj, next);
    }
  };

  const applyFixedConstraintsToObject = (obj: any, constraints?: FixedSlotConstraints) => {
    if (!obj) return;
    const normalized = normalizeFixedSlotConstraints(constraints);
    (obj as any)._asukaFixedConstraints = normalized;
    if ((obj as any)._asukaFixedSlotId) {
      (obj as any)._asukaFixedSlotConstraints = normalized;
    }

    const anchor = resolveFixedConstraintAnchor(obj);
    obj.lockMovementX = normalized.lockX === true;
    obj.lockMovementY = normalized.lockY === true;
    if (normalized.lockX) (obj as any).__asukaFixedLockedCenterX = Number.isFinite(Number((obj as any).__asukaFixedLockedCenterX)) ? Number((obj as any).__asukaFixedLockedCenterX) : anchor.x;
    else (obj as any).__asukaFixedLockedCenterX = null;
    if (normalized.lockY) (obj as any).__asukaFixedLockedCenterY = Number.isFinite(Number((obj as any).__asukaFixedLockedCenterY)) ? Number((obj as any).__asukaFixedLockedCenterY) : anchor.y;
    else (obj as any).__asukaFixedLockedCenterY = null;

    try { obj.lockRotation = normalized.lockRotation === true; } catch {}
    try { if (normalized.lockRotation) (obj as any).__asukaFixedLockedAngle = Number(obj.angle ?? 0) || 0; else (obj as any).__asukaFixedLockedAngle = null; } catch {}
    try { obj.hasRotatingPoint = normalized.lockRotation !== true; } catch {}
    try {
      if (typeof obj.setControlsVisibility === 'function') {
        obj.setControlsVisibility({
          mt: false,
          mb: false,
          ml: false,
          mr: false,
          mtr: normalized.lockRotation !== true,
          rotateControl: normalized.lockRotation !== true,
        });
      }
    } catch {}
    try { obj.setControlVisible?.('mt', false); } catch {}
    try { obj.setControlVisible?.('mb', false); } catch {}
    try { obj.setControlVisible?.('ml', false); } catch {}
    try { obj.setControlVisible?.('mr', false); } catch {}
    try { obj.setControlVisible?.('mtr', normalized.lockRotation !== true); } catch {}
    try { obj.setControlVisible?.('rotateControl', normalized.lockRotation !== true); } catch {}
    try {
      if (normalized.lockRotation && obj.controls) {
        delete (obj.controls as any).mtr;
        delete (obj.controls as any).rotateControl;
      }
    } catch {}

    const isIndicator = !!(obj as any)._asukaFixedSlotIsIndicator;

    if (!isIndicator) {
      try {
        const allowedFonts = Array.isArray(normalized.allowedFonts) ? normalized.allowedFonts : [];
        if (allowedFonts.length) {
          walkFixedConstraintTargets(obj, (target) => {
            if (typeof (target as any).fontFamily !== 'string') return;
            const current = String((target as any).fontFamily || '');
            if (!allowedFonts.includes(current)) {
              (target as any).fontFamily = allowedFonts[0];
              (target as any).dirty = true;
            }
          });
        }
      } catch {}

      try {
        const allowedColors = Array.isArray(normalized.allowedColors) ? normalized.allowedColors : [];
        if (allowedColors.length) {
          walkFixedConstraintTargets(obj, (target) => {
            const fill = (target as any).fill;
            if (typeof fill === 'string' && !allowedColors.includes(fill)) {
              (target as any).set?.({ fill: allowedColors[0] });
              (target as any).dirty = true;
            }

            const stroke = (target as any).stroke;
            if (typeof stroke === 'string' && !allowedColors.includes(stroke)) {
              (target as any).set?.({ stroke: allowedColors[0] });
              (target as any).dirty = true;
            }
          });
        }
      } catch {}

      try {
        if (typeof (obj as any).text === 'string') {
          let textValue = String((obj as any).text ?? '');
          if (typeof normalized.maxChars === 'number') textValue = textValue.slice(0, normalized.maxChars);
          if (typeof normalized.maxLines === 'number') textValue = textValue.split(/\r?\n/).slice(0, normalized.maxLines).join('\n');
          if (textValue !== (obj as any).text) {
            (obj as any).text = textValue;
            (obj as any).dirty = true;
          }
          (obj as any).__asukaFixedLastValidText = String((obj as any).text ?? '');
        }
      } catch {}

      try { clampFixedConstraintScale(obj, normalized); } catch {}
    }

    enforceFixedConstraintPosition(obj, normalized);
    try { obj.setCoords?.(); } catch {}
  };

  const centerObjectOnFixedSlot = (obj: any, slot: any) => {
    if (!obj || !slot) return;
    const x = Number(slot.x ?? NaN);
    const y = Number(slot.y ?? NaN);
    if (!Number.isFinite(x) || !Number.isFinite(y)) return;
    try { setFixedObjectCenter(obj, { x, y }); } catch {}
    try {
      (obj as any).__asukaFixedLockedCenterX = x;
      (obj as any).__asukaFixedLockedCenterY = y;
    } catch {}
  };

  const attachObjectToFixedSlot = (obj: any, slot: any) => {
    if (!obj || !slot) return;
    const slotId = String(slot.id ?? '');
    if (!slotId) return;

    const targetSize = getFixedSlotTargetSize(slot);

    (obj as any)._asukaFixedSlotId = slotId;
    (obj as any)._asukaFixedSlotType = String(slot.type ?? (obj as any)._asukaFixedSlotType ?? 'text');
    (obj as any)._asukaFixedSlotColor = typeof slot.color === 'string' ? slot.color : undefined;
    (obj as any)._asukaFixedSlotTargetWidth = targetSize.targetWidth;
    (obj as any)._asukaFixedSlotTargetHeight = targetSize.targetHeight;
    (obj as any)._asukaBringToFront = Boolean(slot.bringToFront);
    (obj as any).bringToFront = Boolean(slot.bringToFront);
    (obj as any)._asukaFixedSlotConstraints = normalizeFixedSlotConstraints(slot.constraints ?? {});
    (obj as any)._asukaFixedSlotIsIndicator = false;
    (obj as any)._asukaFixedSlotIsContent = true;

    centerObjectOnFixedSlot(obj, slot);
    applyFixedSlotTargetSizeToObject(obj, slot);
    applyFixedConstraintsToObject(obj, (obj as any)._asukaFixedSlotConstraints);
    centerObjectOnFixedSlot(obj, slot);
  };


  const captureCanvasObjectIds = () => {
    const ids = new Set<string>();
    try {
      for (const obj of canvas.getObjects?.() ?? []) {
        const id = ensureObjectId(obj);
        if (id) ids.add(String(id));
      }
    } catch {}
    return ids;
  };

  const resolveAddedCanvasObject = (beforeIds?: Set<string> | null, slot?: any) => {
    const active = canvas.getActiveObject?.();
    const activeId = active ? String(ensureObjectId(active) ?? '') : '';
    if (active && (!beforeIds || !beforeIds.has(activeId))) {
      return active;
    }

    const objects = canvas.getObjects?.() ?? [];
    const newObjects: any[] = [];
    for (let i = objects.length - 1; i >= 0; i -= 1) {
      const obj: any = objects[i];
      if (!obj) continue;
      if (obj._asukaFixedSlotIsIndicator) continue;
      const id = String(ensureObjectId(obj) ?? '');
      if (!id) continue;
      if (!beforeIds || !beforeIds.has(id)) newObjects.push(obj);
    }

    if (slot && newObjects.length > 1) {
      const sx = Number(slot?.x ?? NaN);
      const sy = Number(slot?.y ?? NaN);
      if (Number.isFinite(sx) && Number.isFinite(sy)) {
        newObjects.sort((a: any, b: any) => {
          const ac = (() => { try { return a.getCenterPoint?.(); } catch { return null; } })();
          const bc = (() => { try { return b.getCenterPoint?.(); } catch { return null; } })();
          const ad = Math.hypot(Number(ac?.x ?? a?.left ?? 0) - sx, Number(ac?.y ?? a?.top ?? 0) - sy);
          const bd = Math.hypot(Number(bc?.x ?? b?.left ?? 0) - sx, Number(bc?.y ?? b?.top ?? 0) - sy);
          return ad - bd;
        });
      }
    }

    return newObjects[0] ?? active ?? null;
  };

  const finalizeFixedSlotReplacement = (obj: any, slot: any) => {
    if (!obj || !slot) return obj ?? null;

    attachObjectToFixedSlot(obj, slot);

    try {
      obj.visible = true;
      obj.opacity = typeof obj.opacity === 'number' ? Math.max(obj.opacity, 1) : 1;
      obj.evented = true;
      obj.selectable = true;
      (obj as any).excludeFromExport = false;
      obj.setCoords?.();
    } catch {}

    const objectId = ensureObjectId(obj);
    let runtimeObject = obj;

    try { syncDocumentObjectsForView(); } catch {}

    try {
      const record = findDocumentObjectRecord(objectId);
      if (record) {
        runtimeObject = replaceDocumentObjectFromRuntime(objectId, obj, { viewId: record.viewId, rebuild: false }) ?? runtimeObject;
      }
    } catch {}

    try { syncDocumentObjectsForView(); } catch {}
    try { fixedSlotsRuntime?.ensureIndicatorsForSlots?.(); } catch {}
    try { useAsukaStore.getState().setFixedSlotActive?.(null); } catch {}
    try {
      const active = (instance as any).getObjectById?.(objectId) ?? runtimeObject ?? obj;
      if (active) {
        active.visible = true;
        active.opacity = typeof active.opacity === 'number' ? Math.max(active.opacity, 1) : 1;
        active.evented = true;
        active.selectable = true;
        active.setCoords?.();
        canvas.setActiveObject?.(active);
      }
    } catch {}
    try { canvas.requestRenderAll?.(); } catch {}

    return (instance as any).getObjectById?.(objectId) ?? runtimeObject ?? obj;
  };

  const fixedSlotsRuntime = createFixedSlotsRuntime({
    canvas,
    fabric,
    getFixedSlotsState,
    findSlotById,
    ensureObjectId,
    applyFixedConstraintsToObject,
  });

  const createFixedSlotIndicator = fixedSlotsRuntime.createIndicator;
  const consumeActiveFixedSlot = fixedSlotsRuntime.consumeActiveSlot;

  let fixedSlotPlacementInteractionSnapshot: {
    selection: any;
    skipTargetFind: any;
    defaultCursor: any;
    hoverCursor: any;
  } | null = null;

  const setFixedSlotPlacementInteractionMode = (active: boolean) => {
    try {
      if (active) {
        if (!fixedSlotPlacementInteractionSnapshot) {
          fixedSlotPlacementInteractionSnapshot = {
            selection: (canvas as any).selection,
            skipTargetFind: (canvas as any).skipTargetFind,
            defaultCursor: (canvas as any).defaultCursor,
            hoverCursor: (canvas as any).hoverCursor,
          };
        }

        // Marker placement must behave like a canvas-level tool: clicking over an
        // existing object should place the marker at the pointer, not select/open
        // the object underneath. skipTargetFind disables Fabric target picking
        // while the placement mode is active; the mouse handler below still uses
        // the raw pointer position to create the marker anywhere on the canvas.
        (canvas as any).skipTargetFind = true;
        (canvas as any).selection = false;
        (canvas as any).defaultCursor = 'crosshair';
        (canvas as any).hoverCursor = 'crosshair';
        try { canvas.discardActiveObject?.(); } catch {}
        try { canvas.requestRenderAll?.(); } catch {}
        return;
      }

      if (fixedSlotPlacementInteractionSnapshot) {
        (canvas as any).selection = fixedSlotPlacementInteractionSnapshot.selection;
        (canvas as any).skipTargetFind = fixedSlotPlacementInteractionSnapshot.skipTargetFind;
        (canvas as any).defaultCursor = fixedSlotPlacementInteractionSnapshot.defaultCursor;
        (canvas as any).hoverCursor = fixedSlotPlacementInteractionSnapshot.hoverCursor;
        fixedSlotPlacementInteractionSnapshot = null;
        try { canvas.requestRenderAll?.(); } catch {}
      }
    } catch {
      // Non-critical UI guard: marker placement still works through the mouse handler.
    }
  };

  let unsubscribeFixedSlotPlacementInteraction: null | (() => void) = null;
  try {
    setFixedSlotPlacementInteractionMode(!!getFixedSlotsState().placing);
    unsubscribeFixedSlotPlacementInteraction = (useAsukaStore as any).subscribe((state: any, previous: any) => {
      const active = !!state?.fixedSlotsPlacing;
      const wasActive = !!previous?.fixedSlotsPlacing;
      if (active !== wasActive) {
        setFixedSlotPlacementInteractionMode(active);
      }
    });
  } catch {
    unsubscribeFixedSlotPlacementInteraction = null;
  }

  try {
    const g: any = globalThis as any;
    g.__ASUKA_MARKERS_DEBUG__ = g.__ASUKA_MARKERS_DEBUG__ ?? {};
    g.__ASUKA_MARKERS_DEBUG__.dumpCanvasObjects = () => (canvas.getObjects?.() ?? []).map((o: any) => ({
      objectId: o?._asukaObjectId ?? null,
      slotId: o?._asukaFixedSlotId ?? null,
      isIndicator: !!o?._asukaFixedSlotIsIndicator,
      isContent: !!o?._asukaFixedSlotIsContent,
      type: o?.type ?? o?.constructor?.name ?? null,
      visible: o?.visible !== false,
      opacity: typeof o?.opacity === 'number' ? o.opacity : null,
      left: typeof o?.left === 'number' ? o.left : null,
      top: typeof o?.top === 'number' ? o.top : null,
      itemGroup: o?.itemGroup ?? null,
      children: Array.isArray(o?._objects) ? o._objects.length : null,
    }));
    g.__ASUKA_MARKERS_DEBUG__.dumpDocumentObjects = () => {
      const state: any = useAsukaStore.getState?.();
      const activeViewId = String(state?.activeViewId ?? '').trim();
      const docs = activeViewId ? (getDocumentObjectsState()?.[activeViewId] ?? []) : [];
      return { activeViewId, objects: Array.isArray(docs) ? docs : [] };
    };
    g.__ASUKA_MARKERS_DEBUG__.dumpFixedSlots = () => {
      const state: any = useAsukaStore.getState?.();
      return {
        activeId: state?.fixedSlotActiveId ?? null,
        placing: state?.fixedSlotsPlacing ?? null,
        slots: Array.isArray(state?.fixedSlots) ? state.fixedSlots : [],
      };
    };
  } catch {}


  // ---------------------------------------------------------------------------
  // Snapping config (declared early because pointer handlers need to know
  // whether "raw pointer baseline" should be used during moves).
  // ---------------------------------------------------------------------------

  // IMPORTANT: this must be defined before any pointer/canvas handlers that
  // reference it (mouse:down / object:moving / snapping helpers).
  const snapConfig: {
    enabled: boolean;
    threshold: number;
    release: number;
    priority: 'zones-first' | 'objects-first';
  } = (() => {
    const threshold =
      typeof options.snapConfig?.threshold === 'number' && !Number.isNaN(options.snapConfig.threshold)
        ? Math.max(0, options.snapConfig.threshold)
        : 6;
    const release =
      typeof options.snapConfig?.release === 'number' && !Number.isNaN(options.snapConfig.release)
        ? Math.max(0, options.snapConfig.release)
        : threshold + 12;
    const priority = options.snapConfig?.priority === 'zones-first' ? 'zones-first' : 'objects-first';
    return {
      enabled: true,
      threshold,
      release,
      priority,
    };
  })();

  let __historyNeedsCommitAfterTransform = false;
  let __historyTransformTarget: any = null;
  let __nativeTransformReleaseTimer: number | null = null;

  const commitPendingInteractiveTransform = (candidate: any, reason: string) => {
    const historyTarget = candidate ?? __historyTransformTarget ?? canvas.getActiveObject?.();
    try {
      if (__historyNeedsCommitAfterTransform && historyTarget && isHistoryRelevantObject(historyTarget)) {
        __historyNeedsCommitAfterTransform = false;
        scheduleHistorySnapshot({ force: true, reason });
      }
    } catch {}
    __historyTransformTarget = null;
  };

  // A contextual overlay can move under the pointer during a drag and stop the
  // bubbling mouseup before Fabric sees it. Capture the native release, let the
  // regular event dispatch finish, then finalize any transform Fabric left open.
  const __onNativeTransformRelease = (event: Event) => {
    const targetAtRelease = __historyTransformTarget ?? canvas.getActiveObject?.();
    if (__nativeTransformReleaseTimer != null) window.clearTimeout(__nativeTransformReleaseTimer);
    __nativeTransformReleaseTimer = window.setTimeout(() => {
      __nativeTransformReleaseTimer = null;
      const currentTransform = (canvas as any)?._currentTransform;
      if (!__historyNeedsCommitAfterTransform && !currentTransform) return;
      const transformedTarget = currentTransform?.target ?? targetAtRelease;
      try {
        if (currentTransform && typeof (canvas as any).endCurrentTransform === 'function') {
          (canvas as any).endCurrentTransform(event as any);
        }
      } catch {}
      try { delete (transformedTarget as any)?.__asukaGrab; } catch {}
      isLiveTransform = false;
      try { snapLinesRuntime.hide(); } catch {}
      commitPendingInteractiveTransform(transformedTarget, 'interactive-transform:native-release');
      try { canvas.requestRenderAll?.(); } catch {}
    }, 0);
  };

  const __nativeTransformReleaseEvent = (canvas as any).enablePointerEvents ? 'pointerup' : 'mouseup';
  document.addEventListener(__nativeTransformReleaseEvent, __onNativeTransformRelease, true);
  if (!(canvas as any).enablePointerEvents) {
    document.addEventListener('touchend', __onNativeTransformRelease, true);
  }

  // --- Pointer-based RAW move capture (prevents snapping "bump"/oscillation)
  // We store the pointer->object offset on mouse down, and reconstruct the RAW left/top from the pointer each frame.
  canvas.on('mouse:down', (opt: any) => {
    const target = opt?.target;
    const e = opt?.e;
    if (!e) return;
// Drawing-zone pick mode: when active, only accept clicks on highlighted zones.
if (__zonePickActive) {
  if (target && (target as any)._asukaRole === 'drawingZonePicker' && (target as any)._asukaZonePickId) {
    try {
      const zid = String((target as any)._asukaZonePickId);
      finishZonePick(zid);
    } catch (err) {
      cancelZonePick(err);
    }
  }
  // Prevent other interactions while picking
  try { (e as any).preventDefault?.(); } catch {}
  try { (e as any).stopPropagation?.(); } catch {}
  return;
}


    // Fixed slot placement mode: next click creates an indicator at the pointer,
    // regardless of the object currently under the cursor. This tool must take
    // priority over selection, element editor opening, and every other object
    // interaction while it is active.
    const fsState = getFixedSlotsState();
    if (!__isPanning && fsState.placing) {
      try {
        try { (e as any).preventDefault?.(); } catch {}
        try { (e as any).stopPropagation?.(); } catch {}
        try { canvas.discardActiveObject?.(); } catch {}
        const p = getCanvasPointer(e, true);
        const id = `fixed-${Math.random().toString(36).slice(2)}-${Date.now()}`;
        const slot = {
          id,
          type: fsState.placing.type,
          x: p.x,
          y: p.y,
          constraints: fsState.placing.constraints ?? {},
          label: fsState.placing.label,
          color: fsState.placing.color,
          targetWidth: normalizePositiveNumber(fsState.placing.targetWidth),
          targetHeight: normalizePositiveNumber(fsState.placing.targetHeight),
          bringToFront: Boolean((fsState.placing as any).bringToFront),
        };
        useAsukaStore.getState().addFixedSlot?.(slot as any);
        createFixedSlotIndicator(slot);
        canvas.requestRenderAll();
      } catch {}
      return;
    }

    // Shapes placement mode: click+drag on empty canvas.
    // NOTE: we skip when panning is active (Space or middle mouse).
    const shState = getShapesState();
    if (!__isPanning && shState.placing && !target) {
      beginShapePlacement(shState.placing, e as any);
      return;
    }

    // Clicking a FixedSlot *indicator* opens its module and marks it as the active replacement target.
    // IMPORTANT: content objects created *from* a FixedSlot remain normal selectable objects.
    if (target && (target as any)._asukaFixedSlotIsIndicator && (target as any)._asukaFixedSlotId) {
      // Ensure the indicator never becomes a selection target.
      // Also clear any previous selection so the selection toolbar does not stay visible.
      try {
        canvas.discardActiveObject?.();
        canvas.requestRenderAll?.();
      } catch {}

      const slotId = String((target as any)._asukaFixedSlotId);
      const slot = findSlotById(slotId);
      useAsukaStore.getState().setFixedSlotActive?.(slotId);

      const type = String((slot?.type ?? (target as any)._asukaFixedSlotType) ?? 'text') as FixedSlotType;
      const moduleId = type === 'image' ? 'image' : type === 'design' ? 'designs' : 'text';
      try { useAsukaStore.getState().openModule?.(moduleId as any); } catch {}
      return;
    }

    if (!target) return;

    try {
      __historyNeedsCommitAfterTransform = false;
      __historyTransformTarget = null;
    } catch {}

    // Start live transform lock for interactive objects.
    const role = (target as any)?._asukaRole;
    if (target.selectable !== false && role !== 'drawingZone' && role !== 'snapVertical' && role !== 'snapHorizontal' && role !== 'clipPath') {
      isLiveTransform = true;
    }

    // PERF: "raw pointer baseline" is only required to stabilize snapping.
    // When snapping is disabled, letting Fabric handle the interactive move
    // avoids extra per-event work (getPointer + set + setCoords) and reduces lag.
    if (snapConfig.enabled) {
      const p = getCanvasPointer(e, true);
      (target as any).__asukaGrab = {
        dx: p.x - (target.left ?? 0),
        dy: p.y - (target.top ?? 0),
      };
    }
  });

  canvas.on('mouse:up', (opt: any) => {
    const target = opt?.target;

    // Finish shapes placement.
    if (__shapePlacing && !__isPanning) {
      try { void finalizeShapePlacement(); } catch {}
      return;
    }

    if (target && (target as any).__asukaGrab) {
      try { delete (target as any).__asukaGrab; } catch {}
    }

    try { snapLinesRuntime.hide(); } catch {}

    // Release live transform lock on pointer up. (object:modified will also release it)
    isLiveTransform = false;

    commitPendingInteractiveTransform(target, 'interactive-transform:mouse-up');

    // Element instances are not selectable, but are clickable: open the variation picker overlay on click.
    if (isListenerEnabled('clickElement') && !__isPanning && target && (target as any)._asukaElementIsInstance && (target as any).evented !== false && (target as any).selectable !== true) {
      try {
        const objectId = ensureObjectId(target as any);
        const elementName = String((target as any)._asukaElementName ?? '');
        const elementId = String((target as any)._asukaElementId ?? '').trim();
        if (elementId || elementName) {
          const store = useAsukaStore.getState() as any;
          const payload = { objectId, elementId, elementName, variationId: String((target as any)._asukaElementVariationId ?? ''), objectSrc: String((target as any)?.getSrc?.() ?? (target as any)?.src ?? '') };
          try {
            void 0;
            void 0;
            void 0;
          } catch {}
          store.openElementVariationPicker?.(payload);
        }
      } catch (_e) {
        void 0;
      }
    }
  });

  canvas.on('mouse:move', (opt: any) => {
    const e = opt?.e;
    if (!e) return;
    if (__shapePlacing && !__isPanning) {
      updateShapePreview(e as any);
    }
  });

// ---------------------------------------------------------------------------
// F4/F5: Drawing zones (multiple) + active zone selection
// ---------------------------------------------------------------------------

type AsukaDrawingZone = { id: string; left: number; top: number; width: number; height: number; shape?: 'rect' | 'ellipse' | 'triangle' | 'star' | 'svg'; sourceSvg?: string; sourcePath?: string; physicalSize?: import('./physicalMeasurement').AsukaPhysicalSize };

// IMPORTANT: no implicit/default drawing zone.
// Drawing areas are structural objects that belong to a single view.
// A newly created view must start with zero drawing areas.
let drawingZones: AsukaDrawingZone[] = [];

let activeDrawingZoneId: string | null = null;
let drawingAreasInteractionRuntime: any = null;
let rendererLifecycleRuntime: any = null;

// ---------------------------------------------------------------------------
// Interactive drawing-zone picker (used when multiple drawing areas exist)
// When there is exactly one drawing zone, we auto-target it.
// When there are multiple, we highlight them and let the user click the desired one.
// ---------------------------------------------------------------------------

let __zonePickActive = false;
let __zonePickResolve: ((id: string) => void) | null = null;
let __zonePickReject: ((err: any) => void) | null = null;
let __zonePickRects: any[] = [];

// If the user explicitly picked a zone for an upcoming "add" action, we must
// honor that choice for the next created object even if it is spawned at a
// default position that would otherwise fall into another zone.
let __pendingAddZoneId: string | null = null;


let __activeDrawingAreaDraftObjectId: string | null = null;
let __drawingAreaDraftInteractionCleanup: (() => void) | null = null;

const isDrawingAreaDraft = (obj: any): boolean => !!obj && !!((obj as any)._asukaDraftDrawingArea || (obj as any)._asukaRole === 'drawingAreaDraft');

const getObjectWorldBounds = (obj: any): { left: number; top: number; width: number; height: number } | null => {
  if (!obj) return null;
  try { obj.setCoords?.(); } catch {}
  try {
    const coords = typeof obj.getCoords === 'function' ? obj.getCoords() : null;
    const pts = Array.isArray(coords) ? coords.filter(Boolean) : [];
    if (pts.length) {
      const xs = pts.map((p: any) => Number(p.x ?? 0));
      const ys = pts.map((p: any) => Number(p.y ?? 0));
      const minX = Math.min(...xs);
      const maxX = Math.max(...xs);
      const minY = Math.min(...ys);
      const maxY = Math.max(...ys);
      return { left: minX, top: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
    }
  } catch {}
  try {
    const br = obj.getBoundingRect?.(true, true);
    if (br) return { left: Number(br.left ?? 0), top: Number(br.top ?? 0), width: Math.max(1, Number(br.width ?? 1)), height: Math.max(1, Number(br.height ?? 1)) };
  } catch {}
  return null;
};

const finalizeDrawingAreaDraftById = (objectId: string | null | undefined) => drawingAreasInteractionRuntime?.finalizeDrawingAreaDraftById?.(objectId) ?? null;

const clearZonePickRects = () => {
  try { drawingAreasInteractionRuntime?.clearZonePickRects?.(); } catch {}
};

const finishZonePick = (pickedId: string) => {
  try { drawingAreasInteractionRuntime?.finishZonePick?.(pickedId); } catch {}
};

const cancelZonePick = (err?: any) => {
  try { drawingAreasInteractionRuntime?.cancelZonePick?.(err); } catch {}
};

const ensureZoneForAdd = async (): Promise<string> => {
  return await (drawingAreasInteractionRuntime?.ensureZoneForAdd?.() ?? Promise.resolve(String(activeDrawingZoneId ?? getZone().id ?? 'canvas')));
};


// Zone lookup helpers (per-object zone binding)
// Objects can be bound to a specific drawing zone via `_asukaZoneId`.
// If missing, we fall back to the current active drawing zone.
const getZoneById = (id: string | null | undefined): AsukaDrawingZone => {
  if (!id) return getZone();
  const z = drawingZones.find((zz) => zz.id === id);
  return z ?? getZone();
};

const getZoneForObject = (obj: any): AsukaDrawingZone => {
  // Elements are STRUCTURE objects: never bound to drawing areas.
  // They should behave as if the whole canvas is their "zone".
  if ((obj as any)?._asukaElementIsInstance || (obj as any)?._asukaElementName || (obj as any)?._asukaIgnoreDrawingZones) {
    const w = canvas.getWidth?.() ?? logicalWidth;
    const h = canvas.getHeight?.() ?? logicalHeight;
    return { id: 'canvas', left: 0, top: 0, width: w, height: h } as any;
  }
  const zid = (obj as any)?._asukaZoneId as string | undefined;
  return getZoneById(zid ?? activeDrawingZoneId);
};

// ---------------------------------------------------------------------------
// Multi-drawing-areas engine (policies + capacity + placeholders)
// ---------------------------------------------------------------------------

type AreaRuntime = { objectIds: string[]; placeholderId?: string };
const drawingAreaPolicies = new Map<string, DrawingAreaPolicy>();
const drawingAreaRuntime = new Map<string, AreaRuntime>();

const getPolicy = (id: string): DrawingAreaPolicy => drawingAreaPolicies.get(id) ?? {};

const ensureRuntime = (id: string): AreaRuntime => {
  const existing = drawingAreaRuntime.get(id);
  if (existing) return existing;
  const rt: AreaRuntime = { objectIds: [] };
  drawingAreaRuntime.set(id, rt);
  return rt;
};


// Compute inner bounds for a zone using its policy.margin (if any).
const getZoneInnerBounds = (zone: AsukaDrawingZone): { left: number; top: number; width: number; height: number } => {
  const p = getPolicy(zone.id);
  const m = (p?.margin ?? {}) as any;
  const ml = Number(m.left ?? 0);
  const mt = Number(m.top ?? 0);
  const mr = Number(m.right ?? 0);
  const mb = Number(m.bottom ?? 0);
  const left = zone.left + ml;
  const top = zone.top + mt;
  const width = Math.max(1, zone.width - ml - mr);
  const height = Math.max(1, zone.height - mt - mb);
  return { left, top, width, height };
};

// Auto-fit a Fabric object inside bounds: center + scale-down to fit (no upscale).
const autoFitObjectToBounds = (obj: any, bounds: { left: number; top: number; width: number; height: number }, pad = 12) => {
  if (!obj || !bounds) return;
  try { (obj as any)._asukaAutoFitDone = true; } catch {}
  try { obj.set?.({ originX: 'center', originY: 'center', left: bounds.left + bounds.width / 2, top: bounds.top + bounds.height / 2 }); } catch {}
  try { obj.setCoords?.(); } catch {}
  const br = obj.getBoundingRect?.(true, true);
  if (br && br.width > 0 && br.height > 0) {
    const maxW = Math.max(1, bounds.width - pad * 2);
    const maxH = Math.max(1, bounds.height - pad * 2);
    const factor = Math.min(1, maxW / br.width, maxH / br.height);
    if (factor < 1) {
      try {
        obj.scaleX = (obj.scaleX ?? 1) * factor;
        obj.scaleY = (obj.scaleY ?? 1) * factor;
      } catch {}
      try { obj.setCoords?.(); } catch {}
    }
  }
};
const isUserLayer = (obj: any): boolean => {
  if (!obj) return false;
  const role = String((obj as any)._asukaRole ?? '');

  // Never treat tooling / overlays as real user layers.
  if (role === 'tool' || role === 'drawingAreaDraft' || role === 'clipPath' || role === 'snapVertical' || role === 'snapHorizontal') return false;

  // Any drawing-zone visuals (outline/picker/placeholder/preview) must be ignored.
  if (role.startsWith('drawingZone')) return false;

  // Preview helpers must be ignored.
  if (role === 'shapePreview') return false;

  // Elements are treated as tools/structure in AsukaDesigner.
  if ((obj as any)._asukaElementName) return false;

  return true;
};

const isAutoFitTargetLayer = (obj: any): boolean => {
  if (!obj) return false;

  const role = String((obj as any)._asukaRole ?? '').trim();
  const roleLower = role.toLowerCase();

  // Auto-fit is only for real content, never for editor/runtime helpers.
  if (role === 'tool' || role === 'drawingAreaDraft' || role === 'clipPath' || role === 'snapVertical' || role === 'snapHorizontal' || role === 'shapePreview') return false;
  if (role.startsWith('drawingZone')) return false;
  if ((obj as any)._asukaDraftDrawingArea || (obj as any)._asukaFixedSlotIsIndicator) return false;

  // Element instances are content for sizing purposes, even if they are structural
  // for selection/zone constraints.
  if ((obj as any)._asukaElementIsInstance || (obj as any)._asukaElementName) return true;

  const itemGroup = String((obj as any).itemGroup ?? '').trim().toLowerCase();
  if (itemGroup === 'images' || itemGroup === 'image' || itemGroup === 'text' || itemGroup === 'designs' || itemGroup === 'design') return true;

  if ((obj as any)._asukaImage === true || (obj as any)._asukaDesign === true) return true;
  if (roleLower === 'image' || roleLower === 'text' || roleLower === 'design') return true;

  const type = String((obj as any).type ?? '').trim().toLowerCase();
  if (type === 'image' || type === 'text' || type === 'i-text' || type === 'textbox') return true;

  return false;
};

const pointInZonePolygon = (x: number, y: number, points: Array<{ x: number; y: number }>): boolean => {
  let inside = false;
  for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
    const xi = Number(points[i]?.x ?? 0);
    const yi = Number(points[i]?.y ?? 0);
    const xj = Number(points[j]?.x ?? 0);
    const yj = Number(points[j]?.y ?? 0);
    const intersect = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi);
    if (intersect) inside = !inside;
  }
  return inside;
};

const pointInZone = (x: number, y: number, z: AsukaDrawingZone): boolean => {
  const left = Number(z?.left ?? 0);
  const top = Number(z?.top ?? 0);
  const width = Math.max(1, Number(z?.width ?? 0));
  const height = Math.max(1, Number(z?.height ?? 0));
  const shape = String((z as any)?.shape ?? 'rect');
  if (shape === 'ellipse') {
    const rx = width / 2;
    const ry = height / 2;
    const cx = left + rx;
    const cy = top + ry;
    const dx = (x - cx) / Math.max(rx, 1e-9);
    const dy = (y - cy) / Math.max(ry, 1e-9);
    return (dx * dx) + (dy * dy) <= 1;
  }
  if (shape === 'triangle') {
    return pointInZonePolygon(x, y, [
      { x: left + width / 2, y: top },
      { x: left + width, y: top + height },
      { x: left, y: top + height },
    ]);
  }
  if (shape === 'star') {
    const outer = Math.min(width, height) / 2;
    const inner = outer * 0.45;
    const cx = left + width / 2;
    const cy = top + height / 2;
    const pts: Array<{ x: number; y: number }> = [];
    let rot = (Math.PI / 2) * 3;
    const step = Math.PI / 5;
    for (let i = 0; i < 5; i++) {
      pts.push({ x: cx + Math.cos(rot) * outer, y: cy + Math.sin(rot) * outer });
      rot += step;
      pts.push({ x: cx + Math.cos(rot) * inner, y: cy + Math.sin(rot) * inner });
      rot += step;
    }
    return pointInZonePolygon(x, y, pts);
  }
  return x >= left && x <= left + width && y >= top && y <= top + height;
};

// Choose a zone for an object (multi-zones): prefer containment. If multiple
// match, pick the smallest area (more specific).
const findBestZoneForObject = (obj: any): AsukaDrawingZone | null => {
  try {
    const r = obj.getBoundingRect?.(true, true) ?? obj.getBoundingRect?.() ?? null;
    if (!r) return drawingZones[0] ?? null;
    const cx = r.left + r.width / 2;
    const cy = r.top + r.height / 2;

    const candidates = drawingZones.filter((z) => pointInZone(cx, cy, z));
    if (!candidates.length) return null;
    candidates.sort((a, b) => a.width * a.height - b.width * b.height);
    return candidates[0] ?? null;
  } catch {
    return null;
  }
};

const drawingAreaPlaceholderLoading = new Set<string>();

const removeObjectById = (objectId: string) => {
  if (!objectId) return;
  const obj = getRuntimeObjectByIdFast(objectId);
  if (!obj) return;
  try {
    canvas.remove(obj as any);
    untrackRuntimeObject(obj as any);
    canvas.requestRenderAll?.();
  } catch {}
};


  const normalizeInsets = (inset: any) => {
    const n = (x: any) => Math.max(0, Number(x) || 0);
    return {
      top: n(inset?.top),
      right: n(inset?.right),
      bottom: n(inset?.bottom),
      left: n(inset?.left),
    };
  };

  const getZoneInnerRect = (z: any) => {
    const policy = getPolicy(String(z?.id ?? ''));
    const m = normalizeInsets(policy?.margin);
    const left = Number(z?.left ?? 0) + m.left;
    const top = Number(z?.top ?? 0) + m.top;
    const width = Math.max(1, Number(z?.width ?? 0) - m.left - m.right);
    const height = Math.max(1, Number(z?.height ?? 0) - m.top - m.bottom);
    return { left, top, width, height };
  };

  const getZoneClipRect = (z: any) => {
    const policy = getPolicy(String(z?.id ?? ''));
    const inset = policy?.clipping?.inset != null ? policy.clipping.inset : policy?.margin;
    const i = normalizeInsets(inset);
    const left = Number(z?.left ?? 0) + i.left;
    const top = Number(z?.top ?? 0) + i.top;
    const width = Math.max(1, Number(z?.width ?? 0) - i.left - i.right);
    const height = Math.max(1, Number(z?.height ?? 0) - i.top - i.bottom);
    return { left, top, width, height, _insetKey: `${i.top},${i.right},${i.bottom},${i.left}` };
  };

const ensureZonePlaceholder = (zoneId: string) => {
  const policy = getPolicy(zoneId);
  const rt = ensureRuntime(zoneId);
  const hasUserObjects = rt.objectIds.length > 0;
  const existingId = rt.placeholderId;

  if (hasUserObjects) {
    drawingAreaPlaceholderLoading.delete(zoneId);
    if (existingId) {
      removeObjectById(existingId);
      rt.placeholderId = undefined;
    }
    return;
  }

  // Already present (or currently loading)
  if (existingId || drawingAreaPlaceholderLoading.has(zoneId)) return;

  const z = getZoneById(zoneId);
  const inner = getZoneInnerRect(z);
  const def = policy.capacity?.emptyVisual;

  // If no emptyVisual is configured, keep drawing areas fully invisible when empty.
  if (!def) return;


  const common: any = {
    selectable: false,
    evented: false,
    excludeFromExport: true,
    objectCaching: true,
  };

  const tagPlaceholder = (o: any) => {
    try { (o as any)._asukaRole = 'drawingZonePlaceholder'; } catch {}
    try { (o as any).itemGroup = 'Tools'; } catch {}
    try { (o as any)._asukaZoneId = zoneId; } catch {}
    return o;
  };

  // --- Empty visual: IMAGE (async)
  if (def?.kind === 'image' && typeof def.src === 'string' && def.src) {
    drawingAreaPlaceholderLoading.add(zoneId);
    try {
      fabric.Image.fromURL(
        def.src,
        (img: any) => {
          try {
            if (rt.objectIds.length > 0) {
              // Zone no longer empty.
              drawingAreaPlaceholderLoading.delete(zoneId);
              return;
            }

            const iw = Math.max(1, Number(img.width) || 1);
            const ih = Math.max(1, Number(img.height) || 1);
            const fit = def.fit === 'cover' ? 'cover' : 'contain';
            const sx = inner.width / iw;
            const sy = inner.height / ih;
            const s = fit === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);

            img.set?.({
              left: inner.left + inner.width / 2,
              top: inner.top + inner.height / 2,
              originX: 'center',
              originY: 'center',
              scaleX: s,
              scaleY: s,
              opacity: typeof def.opacity === 'number' ? def.opacity : 0.35,
              ...common,
            });

            tagPlaceholder(img);
            const pid = ensureObjectId(img as any);
            rt.placeholderId = pid;
            canvas.add(img as any);
            try { canvas.sendToBack?.(img as any); } catch {}
            try { canvas.requestRenderAll?.(); } catch {}
          } finally {
            drawingAreaPlaceholderLoading.delete(zoneId);
          }
        },
        { crossOrigin: 'anonymous' } as any,
      );
      return;
    } catch {
      drawingAreaPlaceholderLoading.delete(zoneId);
      // fallthrough to default
    }
  }

  // --- Empty visual: TEXT
  if (def?.kind === 'text' && typeof def.text === 'string') {
    const tb = new fabric.Textbox(def.text || '', {
      left: inner.left + inner.width / 2,
      top: inner.top + inner.height / 2,
      originX: 'center',
      originY: 'center',
      width: Math.max(1, inner.width),
      textAlign: 'center',
      fontSize: 18,
      fill: 'rgba(0,0,0,0.45)',
      opacity: typeof def.opacity === 'number' ? def.opacity : 0.7,
      ...common,
    } as any);
    tagPlaceholder(tb);
    const pid = ensureObjectId(tb as any);
    rt.placeholderId = pid;
    canvas.add(tb as any);
    try { canvas.sendToBack?.(tb as any); } catch {}
    try { canvas.requestRenderAll?.(); } catch {}
    return;
  }

  // --- Empty visual: SHAPE
  if (def?.kind === 'shape') {
    const opacity = typeof def.opacity === 'number' ? def.opacity : 0.12;
    if (def.shape === 'circle') {
      const r = Math.max(6, Math.min(inner.width, inner.height) / 2);
      const c = new fabric.Circle({
        left: inner.left + inner.width / 2,
        top: inner.top + inner.height / 2,
        originX: 'center',
        originY: 'center',
        radius: r,
        fill: `rgba(0,0,0,${opacity})`,
        stroke: 'rgba(0,0,0,0.15)',
        strokeWidth: 1,
        ...common,
      } as any);
      tagPlaceholder(c);
      const pid = ensureObjectId(c as any);
      rt.placeholderId = pid;
      canvas.add(c as any);
      try { canvas.sendToBack?.(c as any); } catch {}
      try { canvas.requestRenderAll?.(); } catch {}
      return;
    }
  }
};

const enforceZoneCapacity = (zoneId: string, addedObjectId?: string) => {
  const policy = getPolicy(zoneId);
  const maxObjects = policy.capacity?.maxObjects;
  if (!maxObjects || maxObjects <= 0) return;
  const rt = ensureRuntime(zoneId);
  const strategy = policy.capacity?.overflowStrategy ?? 'replaceOldest';
  if (rt.objectIds.length <= maxObjects) return;

  if (strategy === 'reject') {
    // Reject the last added object.
    if (addedObjectId) {
      rt.objectIds = rt.objectIds.filter((id) => id !== addedObjectId);
      removeObjectById(addedObjectId);
      emitAsukaEvent('drawingAreas.capacity.rejected', { areaId: zoneId, objectId: addedObjectId });
    }
    return;
  }

  while (rt.objectIds.length > maxObjects) {
    const oldestId = rt.objectIds.shift();
    if (!oldestId) break;
    removeObjectById(oldestId);
    emitAsukaEvent('drawingAreas.capacity.replacedOldest', { areaId: zoneId, removedObjectId: oldestId, addedObjectId });
  }
};

const registerObjectInZoneRuntime = (zoneId: string, obj: any) => {
  if (!isUserLayer(obj)) return;
  const objectId = ensureObjectId(obj as any);
  const rt = ensureRuntime(zoneId);
  // Remove duplicates then append (treat latest assignment as newest).
  rt.objectIds = rt.objectIds.filter((id) => id !== objectId);
  rt.objectIds.push(objectId);
  // Placeholder hidden when at least one user object exists.
  ensureZonePlaceholder(zoneId);
  enforceZoneCapacity(zoneId, objectId);
};

const getUserObjectsInZone = (zoneId: string): any[] => {
  const ids = ensureRuntime(zoneId).objectIds.slice();
  return getRuntimeObjectsByIds(ids).filter((o: any) => !!o && isUserLayer(o) && String((o as any)._asukaZoneId ?? '') === zoneId);
};

const getAreaPricingRulesFromPolicy = (policy: any): any[] | undefined => {
  const rules = policy?.pricing?.rules;
  if (!Array.isArray(rules) || !rules.length) return undefined;
  // If user provides PricingRule-like objects (handler/conditions), we can evaluate them using pricingEngine.
  if (typeof (rules[0] as any)?.handler === 'string' && Array.isArray((rules[0] as any)?.conditions)) return rules as any[];
  return undefined;
};

const computeDrawingAreaPriceUiBreakdown = (zoneId: string, externalPrice?: number | string) => {
  const policy = getPolicy(zoneId);
  const store: any = useAsukaStore.getState?.() ?? {};
  const base = normalizePriceInput(externalPrice) ?? normalizePriceInput(store?.price) ?? 0;

  const scope = policy?.pricing?.scope ?? 'objects';
  const multiplier = Number(policy?.pricing?.multiplier ?? 1) || 1;
  const areaBase = Number(policy?.pricing?.base ?? 0) || 0;

  const objs = getUserObjectsInZone(zoneId);

  let standard = 0;
  let rules = 0;

  if (scope === 'objects' || scope === 'both') {
    standard = getStandardPrices({ objects: objs, designer: instance as any });
    const rulesOverride = getAreaPricingRulesFromPolicy(policy);
    rules = evaluatePricingRulesForObjects({
      designer: instance as any,
      baseTotal: base + standard,
      objectsOverride: objs,
      rulesOverride,
    });
  }

  // Area-level pricing (independent from objects)
  let area = 0;
  if (scope === 'area' || scope === 'both') {
    area = areaBase;
  }

  const total = (base + standard + rules + area) * multiplier;

  return { base, standard, rules, area, total };
};
  // ---------------------------------------------------------------------------

// Clip paths runtime (zone clipping stays renderer-only)
const clipPathsRuntime = createClipPathsRuntime({
  canvas,
  getZoneClipRect,
  getZoneForObject,
  getPolicy,
  isConstraintsEnabled: () => isListenerEnabled('constraints'),
  listRuntimeObjects: () => runtimeObjectIndex.values(),
});

const {
  createZoneClipObjectSync,
  normalizeSvgObjectToRect,
  applyClipForObject,
} = clipPathsRuntime;


  /**
   * Prepare objects created outside of this file (images/SVGs loaded by helper modules)
   * so they behave like native Asuka objects: zone assignment, clipping and custom controls.
   */

  const getAutoFitBoundsForObject = (obj: any) => {
    if (!(obj as any)?._asukaIgnoreDrawingZones && drawingZones.length > 0) {
      const z = getZoneForObject(obj);
      if (z && z.width > 2 && z.height > 2) return { left: z.left, top: z.top, width: z.width, height: z.height };
    }
    const w = canvas.getWidth?.() ?? logicalWidth;
    const h = canvas.getHeight?.() ?? logicalHeight;
    return { left: 0, top: 0, width: w, height: h };
  };

  const getFontMetadataPatchForFamily = (family: string, fallback: any = {}) => {
    const normalizedFamily = String(family ?? '').trim();
    if (!normalizedFamily) return {} as Record<string, any>;
    const catalog = (((useAsukaStore.getState() as any)?.fontsCatalog ?? []) as any[]);
    const match = catalog.find((entry: any) => String(entry?.family ?? '').trim().toLowerCase() === normalizedFamily.toLowerCase()) ?? null;
    const source = String(match?.source ?? fallback?._asukaFontSource ?? 'system').trim() || 'system';
    const id = String(match?.id ?? fallback?._asukaFontId ?? '').trim();
    return {
      _asukaFontId: id || undefined,
      _asukaFontSource: source,
      _asukaFontCssUrl: String(match?.cssUrl ?? fallback?._asukaFontCssUrl ?? '').trim() || undefined,
      _asukaFontUrl: String(match?.url ?? fallback?._asukaFontUrl ?? '').trim() || undefined,
      _asukaFontFormat: String(match?.format ?? fallback?._asukaFontFormat ?? '').trim() || undefined,
      _asukaFontWeight: match?.weight ?? fallback?._asukaFontWeight,
      _asukaFontStyle: match?.style ?? fallback?._asukaFontStyle,
    };
  };

  const applyFontMetadataToObject = (obj: any, familyOverride?: string | null) => {
    if (!obj) return;
    const family = String(familyOverride ?? (obj as any)?.fontFamily ?? '').trim();
    if (!family) return;
    const patch = getFontMetadataPatchForFamily(family, obj);
    try {
      Object.entries(patch).forEach(([key, value]) => {
        if (value !== undefined) (obj as any)[key] = value;
      });
    } catch {}
  };

  const ensureFontRegisteredFromPayload = (payload: any) => {
    const family = String(payload?.family ?? payload?.fontFamily ?? '').trim();
    if (!family) return null;
    const catalog = (((useAsukaStore.getState() as any)?.fontsCatalog ?? []) as any[]);
    const existing = catalog.find((entry: any) => String(entry?.family ?? '').trim().toLowerCase() === family.toLowerCase()) ?? null;
    if (existing) {
      const nextPatch = getFontMetadataPatchForFamily(family, payload);
      try {
        useAsukaStore.setState((state: any) => ({
          fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) => {
            if (String(entry?.id ?? '') === String(existing?.id ?? '') || String(entry?.family ?? '').trim().toLowerCase() === family.toLowerCase()) {
              return {
                ...entry,
                id: nextPatch._asukaFontId ?? entry?.id,
                source: nextPatch._asukaFontSource ?? entry?.source,
                cssUrl: nextPatch._asukaFontCssUrl ?? entry?.cssUrl,
                url: nextPatch._asukaFontUrl ?? entry?.url,
                format: nextPatch._asukaFontFormat ?? entry?.format,
                weight: nextPatch._asukaFontWeight ?? entry?.weight,
                style: nextPatch._asukaFontStyle ?? entry?.style,
              };
            }
            return entry;
          }),
        }) as any);
      } catch {}
      const resolved = (((useAsukaStore.getState() as any)?.fontsCatalog ?? []) as any[])
        .find((entry: any) => String(entry?.family ?? '').trim().toLowerCase() === family.toLowerCase()) ?? existing;
      void loadRegisteredFont(resolved as any).then(() => {
        try { canvas.requestRenderAll?.(); } catch {}
      }).catch(() => {});
      return resolved;
    }

    let nextFont: any = null;
    const source = String(payload?._asukaFontSource ?? payload?.source ?? 'system').trim();
    try {
      if (source === 'google' && (payload?._asukaFontCssUrl || payload?.cssUrl)) {
        nextFont = normalizeGoogleFont({
          id: payload?._asukaFontId ?? payload?.id,
          family,
          cssUrl: payload?._asukaFontCssUrl ?? payload?.cssUrl,
        } as any);
      } else if (source === 'custom' && (payload?._asukaFontUrl || payload?.url || payload?._asukaFontCssUrl || payload?.cssUrl)) {
        nextFont = normalizeCustomFont({
          id: payload?._asukaFontId ?? payload?.id,
          family,
          url: payload?._asukaFontUrl ?? payload?.url,
          cssUrl: payload?._asukaFontCssUrl ?? payload?.cssUrl,
          format: payload?._asukaFontFormat ?? payload?.format,
          weight: payload?._asukaFontWeight ?? payload?.weight,
          style: payload?._asukaFontStyle ?? payload?.style,
        } as any);
      } else {
        nextFont = normalizeSystemFont({ id: payload?._asukaFontId ?? payload?.id, family } as any);
      }
    } catch {
      nextFont = normalizeSystemFont({ family } as any);
    }

    try {
      useAsukaStore.setState((state: any) => ({
        fontsCatalog: dedupeFonts([...(state?.fontsCatalog ?? []), nextFont]),
      }) as any);
    } catch {}
    void loadRegisteredFont(nextFont as any).then((loaded) => {
      try {
        useAsukaStore.setState((state: any) => ({
          fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) =>
            String(entry?.id ?? '') === String(nextFont?.id ?? '')
              ? { ...entry, loaded: !!loaded, loading: false, error: loaded ? undefined : entry?.error }
              : entry,
          ),
        }) as any);
      } catch {}
      try { canvas.requestRenderAll?.(); } catch {}
    }).catch(() => {});
    return nextFont;
  };

  const hydrateFontsFromSerializedObjects = (input: any) => {
    const visit = (node: any) => {
      if (!node || typeof node !== 'object') return;
      const family = String(node?.fontFamily ?? '').trim();
      if (family) ensureFontRegisteredFromPayload(node);
      const curveSource = (node as any)?._asukaCurvedTextData?.source;
      if (curveSource && typeof curveSource === 'object') {
        const sourceFamily = String((curveSource as any)?.fontFamily ?? '').trim();
        if (sourceFamily) ensureFontRegisteredFromPayload(curveSource);
      }
      const children = Array.isArray((node as any)?.objects)
        ? (node as any).objects
        : Array.isArray((node as any)?._objects)
          ? (node as any)._objects
          : [];
      children.forEach(visit);
    };
    if (Array.isArray(input)) input.forEach(visit);
    else visit(input);
  };

  const autoSizeTextboxToContent = (obj: any, options: { minWidth?: number; maxWidth?: number } = {}) => {
    if (!isFabricTextboxObject(obj)) return;
    const center = (() => {
      try { return obj.getCenterPoint?.() ?? null; } catch { return null; }
    })();
    const prevScaleX = Number((obj as any).scaleX ?? 1) || 1;
    const prevScaleY = Number((obj as any).scaleY ?? 1) || 1;
    const currentWidth = Math.max(2, Number((obj as any).width ?? 0) || 0);
    const explicitLines = String((obj as any).text ?? '').split(/\r?\n/);
    const longestLineLength = explicitLines.reduce(
      (longest, line) => Math.max(longest, Array.from(line).length),
      0,
    );
    const fontSize = Math.max(1, Number((obj as any).fontSize ?? 16) || 16);
    const measurementWidth = Math.min(
      1_000_000,
      Math.max(currentWidth, 65_536, longestLineLength * fontSize * 4 + 1_024),
    );
    try { obj.set?.({ scaleX: 1, scaleY: 1 }); } catch {}
    // Textbox wraps during initDimensions(). Measure once on a deliberately
    // wide box so only explicit newlines split the text while the user types.
    try { obj.set?.({ width: measurementWidth }); } catch { try { obj.width = measurementWidth; } catch {} }
    try { obj.initDimensions?.(); } catch {}
    let measured: number | null = null;
    try {
      const width = Number(obj.calcTextWidth?.());
      if (Number.isFinite(width)) measured = Math.max(0, width);
    } catch {}
    try {
      const lines = Array.isArray((obj as any)._textLines) ? (obj as any)._textLines : [];
      for (let i = 0; i < lines.length; i += 1) {
        const width = Number(obj.getLineWidth?.(i) ?? 0) || 0;
        if (measured === null || width > measured) measured = width;
      }
    } catch {}
    if (measured === null) {
      try {
        const br = obj.getBoundingRect?.(true, true);
        measured = Number(br?.width ?? obj.width ?? 0) || 0;
      } catch {}
    }
    const minWidth = Math.max(2, Number(options.minWidth ?? obj.minWidth ?? 20));
    const requestedMaxWidth = Number(options.maxWidth);
    const maxWidth = Number.isFinite(requestedMaxWidth)
      ? Math.max(minWidth, requestedMaxWidth)
      : undefined;
    const nextWidth = computeTightTextboxWidth(measured ?? 0, { minWidth, maxWidth, padding: 2 });
    try { obj.set?.({ width: nextWidth }); } catch { try { obj.width = nextWidth; } catch {} }
    try { obj.initDimensions?.(); } catch {}
    try { obj.set?.({ scaleX: prevScaleX, scaleY: prevScaleY }); } catch {}
    if (center) {
      try { obj.setPositionByOrigin?.(center, 'center', 'center'); } catch {}
    }
    try { obj.dirty = true; } catch {}
    try { obj.setCoords?.(); } catch {}
  };

  const shouldAutoSizeTextboxForPatch = (patch: Record<string, any> | null | undefined) => {
    if (!patch || typeof patch !== 'object') return false;
    return Object.keys(patch).some((key) => [
      'text',
      'fontFamily',
      'fontSize',
      'fontWeight',
      'fontStyle',
      'charSpacing',
      'lineHeight',
      'styles',
      'splitByGrapheme',
    ].includes(key));
  };

  const isRestoringObjectState = (obj?: any) => {
    try {
      return Boolean(
        (canvas as any).__asukaSnapshotLoading ||
        (canvas as any).__asukaViewRebuilding ||
        (canvas as any).__asukaStateImporting ||
        (canvas as any).__asukaRestoringFromState ||
        (obj as any)?.__asukaRestoredFromState ||
        (obj as any)?._asukaRestoredFromState ||
        (obj as any)?.__asukaSkipAutoFit,
      );
    } catch {
      return false;
    }
  };

  const prepareExternalObject = (obj: any) => {
    if (!obj) return;
    if ((obj as any).__asukaPreparedExternalObject) return;
    try {
      (obj as any).__asukaPreparedExternalObject = true;
    } catch {}
    try {
      ensureObjectId(obj);
    } catch {}
    // Used later for UX/features (filters, scopes, etc.). Default keeps BC.
    try {
      (obj as any).itemGroup = (obj as any).itemGroup ?? 'Other';
    } catch {}
    // Drawing zones: if enabled and object participates in zones.
    try {
      if (!isListenerEnabled('constraints') || (obj as any)._asukaIgnoreDrawingZones) {
        try { obj.clipPath = null; } catch {}
      } else {
        // Multi-zones: assign to the best matching zone by geometry if possible.
        const preset = (obj as any)._asukaZoneId as string | undefined;
        // If the user just picked a zone for an add action, ALWAYS honor it for
        // the next created object regardless of its spawn position.
        const forced = __pendingAddZoneId;
        const best = forced ? getZoneById(forced) : (preset ? getZoneById(preset) : findBestZoneForObject(obj));
        const zid = best ? best.id : (activeDrawingZoneId ?? (drawingZones[0]?.id ?? 'default'));
        try { (obj as any)._asukaZoneId = zid; } catch {}
        try { registerObjectInZoneRuntime(zid, obj); } catch {}
        // Consume the forced zone after the first assignment.
        if (forced) __pendingAddZoneId = null;
      }
    } catch {}
    try {
      if (isListenerEnabled('constraints') && !(obj as any)._asukaIgnoreDrawingZones && drawingZones.length > 0) applyClipForObject(obj);
    } catch {}
    try {
      asukaApplyControls(obj);
    } catch {}
    try {
      applyFontMetadataToObject(obj);
      autoSizeTextboxToContent(obj);
    } catch {}

    // -----------------------------------------------------------------------
    // Auto-fit newly added USER layers to the bounding area (drawing zone if
    // available, otherwise the full canvas).
    // Emits:
    //   - before:designer.autoFitLayerToBounds
    //   - after:designer.autoFitLayerToBounds
    // -----------------------------------------------------------------------
    try {
      // A variation replacement already owns the exact frame of the layer it
      // replaces. Treating it like a fresh upload would apply the 12 px inset
      // again and make every selected variation slightly smaller.
      const canAutoFit = isAutoFitTargetLayer(obj)
        && !isRestoringObjectState(obj)
        && !hasFabricVariationFrame(obj);

      if (canAutoFit) {
        const pad = 12;
        const bounds = getAutoFitBoundsForObject(obj);

        emitBefore('designer.autoFitLayerToBounds', { object: obj, bounds });

        // Center, then scale down to fit.
        try { obj.set?.({ originX: 'center', originY: 'center', left: bounds.left + bounds.width / 2, top: bounds.top + bounds.height / 2 }); } catch {}
        try { obj.setCoords?.(); } catch {}

        const br = obj.getBoundingRect?.(true, true);
        if (br && br.width > 0 && br.height > 0) {
          const maxW = Math.max(1, bounds.width - pad * 2);
          const maxH = Math.max(1, bounds.height - pad * 2);
          const factor = Math.min(1, maxW / br.width, maxH / br.height);
          if (factor < 1) {
            try {
              obj.scaleX = (obj.scaleX ?? 1) * factor;
              obj.scaleY = (obj.scaleY ?? 1) * factor;
            } catch {}
            try { obj.setCoords?.(); } catch {}
          }
        }

        emitAfter('designer.autoFitLayerToBounds', { object: obj, bounds });
      }
    } catch {}
    try {
      obj.setCoords?.();
    } catch {}
  };
const getZone = (): AsukaDrawingZone => {
  // IMPORTANT:
  // Zones are expressed in the *document coordinate space* (active view size),
  // not in the host canvas pixel size. The host canvas can be responsive and
  // scaled via viewportTransform; using its pixel dimensions here would create
  // inconsistent snapping guides ("mystery" smaller guides) and constraints.
  if (!drawingZones.length) {
    const w = Math.max(1, Number(docWidth) || Number(logicalWidth) || 1);
    const h = Math.max(1, Number(docHeight) || Number(logicalHeight) || 1);
    return { id: 'document', left: 0, top: 0, width: w, height: h };
  }
  const byId = activeDrawingZoneId ? drawingZones.find((z) => z.id === activeDrawingZoneId) : null;
  return byId ?? drawingZones[0];
};

// ---------------------------------------------------------------------------
// Zone constraints helpers (inside / partial / outside)
// We colorize ONLY the selection border (borderColor), never the object stroke.
// ---------------------------------------------------------------------------

function getObjectBounds(obj: any) {
  const br = obj?.getBoundingRect?.(true, true);
  if (br) return br;
  const left = obj?.left ?? 0;
  const top = obj?.top ?? 0;
  const width = (obj?.width ?? 0) * (obj?.scaleX ?? 1);
  const height = (obj?.height ?? 0) * (obj?.scaleY ?? 1);
  return { left, top, width, height };
}

function getFabricPlacementTransform(obj: any): AsukaObjectTransform | null {
  if (!obj) return null;
  const bounds = getObjectBounds(obj);
  const objectId = ensureObjectId(obj);
  return {
    id: objectId,
    space: 'canvas',
    unit: 'canvas-px',
    x: Number(obj.left ?? bounds.left ?? 0) || 0,
    y: Number(obj.top ?? bounds.top ?? 0) || 0,
    z: 0,
    width: Math.max(0, Number(bounds.width ?? 0) || 0),
    height: Math.max(0, Number(bounds.height ?? 0) || 0),
    rotation: Number(obj.angle ?? 0) || 0,
    rotationZ: Number(obj.angle ?? 0) || 0,
    scaleX: Number(obj.scaleX ?? 1) || 1,
    scaleY: Number(obj.scaleY ?? 1) || 1,
    scaleZ: 1,
  };
}

function beginFabricPlacement(obj: any, kind: string): AsukaPlacementStatus | null {
  if (!obj) return null;
  const objectId = ensureObjectId(obj);
  const current = placementController.getStatus();
  if (current?.objectId === objectId && !['committed', 'cancelled'].includes(current.phase)) {
    return current;
  }
  const zone = getZoneForObject(obj);
  return emitFabricPlacement('placement.begin', placementController.begin({
    objectId,
    printAreaId: String(zone?.id ?? '').trim() || null,
    transform: getFabricPlacementTransform(obj),
    source: 'pointer',
    metadata: { kind },
  }));
}

function updateFabricPlacement(obj: any, position: PositionStatus): AsukaPlacementStatus | null {
  if (!obj) return null;
  const objectId = ensureObjectId(obj);
  const current = placementController.getStatus();
  if (!current || current.objectId !== objectId || ['committed', 'cancelled'].includes(current.phase)) return null;
  const zone = getZoneForObject(obj);
  const inside = position === 'inside';
  const partial = position === 'partial';
  return emitFabricPlacement('placement.update', placementController.update({
    transform: getFabricPlacementTransform(obj),
    printAreaId: String(zone?.id ?? '').trim() || null,
    valid: inside || partial,
    constrained: partial,
    remember: inside,
    reason: partial ? 'partially-outside-area' : (inside ? null : 'outside-area'),
    metadata: { position },
  }));
}

function commitFabricPlacement(): AsukaPlacementStatus | null {
  const current = placementController.getStatus();
  if (!current || ['committed', 'cancelled'].includes(current.phase)) return current;
  return emitFabricPlacement('placement.commit', placementController.commit());
}

function classifyObjectPosition(obj: any, zone: AsukaDrawingZone): PositionStatus {
  if (!obj || !zone) return 'inside';
  const b = getObjectBounds(obj);
  const zLeft = zone.left;
  const zTop = zone.top;
  const zRight = zone.left + zone.width;
  const zBottom = zone.top + zone.height;
  const oLeft = b.left;
  const oTop = b.top;
  const oRight = b.left + b.width;
  const oBottom = b.top + b.height;

  const fullyInside = oLeft >= zLeft && oTop >= zTop && oRight <= zRight && oBottom <= zBottom;
  if (fullyInside) return 'inside';

  const fullyOutside = oRight < zLeft || oBottom < zTop || oLeft > zRight || oTop > zBottom;
  if (fullyOutside) return 'outside';

  return 'partial';
}

function updateObjectStrokeForZone(obj: any, status: PositionStatus) {
  if (!obj) return;
  // Figma-like feedback: selection border only.
  const color = status === 'inside' ? '#4f8cff' : status === 'partial' ? '#f59e0b' : '#ef4444';
  try {
    obj.borderColor = color;
    obj.cornerColor = color;
    obj.cornerStrokeColor = color;
  } catch {}
}

function rememberLastGoodPosition(obj: any, status: PositionStatus) {
  if (!obj) return;
  if (status === 'inside') {
    try {
      (obj as any)._asukaLastGood = {
        left: obj.left,
        top: obj.top,
        scaleX: obj.scaleX,
        scaleY: obj.scaleY,
        angle: obj.angle,
      };
    } catch {}
  }
  updateFabricPlacement(obj, status);
}

function revertToLastGoodPositionIfNeeded(obj: any, status: PositionStatus) {
  if (!obj) return;
  if (status !== 'outside') return;
  updateFabricPlacement(obj, status);
  const placement = placementController.getStatus();
  const sessionTransform = placement?.objectId === ensureObjectId(obj)
    ? placement.lastValidTransform
    : null;
  const last = sessionTransform
    ? {
        left: sessionTransform.x,
        top: sessionTransform.y,
        scaleX: sessionTransform.scaleX,
        scaleY: sessionTransform.scaleY,
        angle: sessionTransform.rotation ?? sessionTransform.rotationZ,
      }
    : (obj as any)._asukaLastGood;
  if (!last) return;
  try {
    obj.set({
      left: last.left,
      top: last.top,
      scaleX: last.scaleX,
      scaleY: last.scaleY,
      angle: last.angle,
    });
    obj.setCoords?.();
  } catch {}
}

  // ---------------------------------------------------------------------------
  // Drawing zone outline (performance-friendly)
  // ---------------------------------------------------------------------------
  const selectionOutlineRuntime = createSelectionOutlineRuntime({
    canvas,
    getDrawingZones: () => drawingZones,
    createZoneClipObjectSync,
    normalizeSvgObjectToRect,
  });

  const rebuildDrawingZoneIndex = selectionOutlineRuntime.rebuildDrawingZoneIndex;
  const removeSelectedZoneOutline = selectionOutlineRuntime.removeSelectedZoneOutline;
  const syncSelectedZoneOutlineFromSelection = selectionOutlineRuntime.syncSelectedZoneOutlineFromSelection;

  // ---------------------------------------------------------------------------
  // Curved text guides (arc/circle/path helpers)
  // ---------------------------------------------------------------------------
  const curvedGuideRuntime = createCurvedGuideRuntime({
    canvas,
    isCurvedPathTextObject,
    getCurvedPathInnerText,
  });
  const hideCurvedGuides = curvedGuideRuntime.hide;
  const showCurvedGuides = curvedGuideRuntime.show;
  const maybeUpdateCurvedGuides = curvedGuideRuntime.maybeUpdate;

  attachSelectionEventsRuntime({
    canvas,
    fabric,
    activeDrawingZoneId,
    useAsukaStore,
    syncDocumentSelectionState,
    emitSelectionChange,
    applyClipForObject,
    isCurvedPathTextObject,
    getCurvedPathInnerText,
    ensureCurveHandlesForObject,
    ensureCurvedHandlesHidden,
    asukaEnsureCenterTransformOrigin,
    showCurvedGuides,
    hideCurvedGuides,
    syncSelectedZoneOutlineFromSelection,
    removeSelectedZoneOutline,
    finalizeDrawingAreaDraftById,
    isDrawingAreaDraft,
    ensureObjectId,
    getActiveDrawingAreaDraftObjectId: () => __activeDrawingAreaDraftObjectId,
    setActiveDrawingAreaDraftObjectId: (id: string | null) => { __activeDrawingAreaDraftObjectId = id; },
    isConstraintsEnabled: () => isListenerEnabled('constraints') && (isListenerEnabled('adjustBoundingBoxColors') || isListenerEnabled('boundingBoxesBorders') || isListenerEnabled('preventBoundingBoxExit')),
    getZoneForObject,
    classifyObjectPosition,
    updateObjectStrokeForZone,
    rememberLastGoodPosition,
  });

  // Keep a nullable reference for drawing-area geometry comparisons.
  let drawingZoneRect: any = null;

  // ---------------------------------------------------------------------------
  // Snap line runtime (renderer-only visuals)
  // ---------------------------------------------------------------------------

  const snapLinesRuntime = createSnapLinesRuntime({
    canvas,
    getZoneRect: () => getZone(),
  });

const syncDrawingZoneVisuals = () => {
  // Keep fast lookup up to date
  rebuildDrawingZoneIndex();

  // If we are currently showing an outline, refresh its geometry in case the zone changed.
  try {
    selectionOutlineRuntime.refreshSelectedZoneOutline();
  } catch {}

  // Rebuild clip paths + re-apply to objects (so clipping stays correct after zone changes)
  try {
    clipPathsRuntime.clearCache();
    for (const obj of runtimeObjectIndex.values()) {
      applyClipForObject(obj);
    }
  } catch {}
  try {
    snapLinesRuntime.remove();
  } catch {}

  canvas.requestRenderAll?.();
};

  // ---------------------------------------------------------------------------
  // Selection change normalizer (used by selection menu + UI)
  // ---------------------------------------------------------------------------

  function emitSelectionChange(source: string) {
    const ids = getActiveObjectIds(canvas);
    emitAsukaEvent('canvas.selection.change', {
      source,
      hasSelection: ids.length > 0,
      objectIds: ids,
    }, { instanceId });
  }

  // ---------------------------------------------------------------------------
  // Transform tracking (for UI + events)
  // ---------------------------------------------------------------------------

  let isTransforming = false;
  let currentTransformObjectId: string | null = null;
  let currentTransformKind: string = 'unknown';

  const startTransform = (kind: string, obj: any) => {
    const target = obj;
    if (!target) return;
    const objectId = ensureObjectId(target);
    if (!isTransforming || currentTransformObjectId !== objectId || currentTransformKind !== kind) {
      isTransforming = true;
      currentTransformObjectId = objectId;
      currentTransformKind = kind;
      beginFabricPlacement(target, kind);
      // Avoid emitting live transform events while dragging/scaling.
      if (!isLiveTransform) {
        emitAsukaEvent('canvas.transform.start', { objectId, kind }, { instanceId });
      }
    }
  };

  // ---------------------------------------------------------------------------
  // History (undo / redo / reset) - snapshots of canvas JSON
  // ---------------------------------------------------------------------------

  const EXTRA_SERIALIZE_PROPS = [
    'id',
    'objectId',
    'asukaId',
    '_asukaObjectId',
    '_asukaZoneId',
    '_asukaRole',
    '_asukaLastGood',
    // Element instances (must survive undo/redo)
    '_asukaElementIsInstance',
    '_asukaElementId',
    '_asukaElementFamilyId',
    '_asukaElementName',
    '_asukaElementVariationId',
    FABRIC_VARIATION_FRAME_PROPERTY,
    '_asukaIgnoreDrawingZones',
    'itemGroup',
    'layerIndex',
    'price',
    'printingMethodId',
    // Interaction flags (Fabric defaults would otherwise make objects selectable again after loadFromJSON)
    'selectable',
    'evented',
    'hasControls',
    'hasBorders',
    'hoverCursor',
    'perPixelTargetFind',
    'targetFindTolerance',
    FABRIC_LAYER_LOCK_PROPERTY,
    FABRIC_LAYER_LOCK_RESTORE_PROPERTY,
    'lockMovementX',
    'lockMovementY',
    'lockScalingX',
    'lockScalingY',
    'lockRotation',
    'excludeFromExport',
    '_asukaBringToFront',
    '_asukaOrderingSeq',
    '_asukaFontId',
    '_asukaFontSource',
    '_asukaFontCssUrl',
    '_asukaFontUrl',
    '_asukaFontFormat',
    '_asukaFontWeight',
    '_asukaFontStyle',
    // Fixed slots / markers
    '_asukaFixedSlotId',
    '_asukaFixedSlotType',
    '_asukaFixedSlotConstraints',
    '_asukaFixedConstraints',
    '_asukaFixedSlotIsContent',
    '_asukaFixedSlotIsIndicator',
    '__asukaFixedLockedCenterX',
    '__asukaFixedLockedCenterY',
    '__asukaFixedLockedAngle',
  ];

  type HistorySnapshot = any;
  const MAX_HISTORY_SNAPSHOTS = 8;
  const DOCUMENT_HISTORY_VIEW_ID = '__document__';
  type HistoryTrackState = {
    stack: HistorySnapshot[];
    index: number;
    initial: HistorySnapshot | null;
    lastSignature: string;
  };
  let historyStack: HistorySnapshot[] = [];
  let historyIndex = -1;
  let initialSnapshot: HistorySnapshot | null = null;
  let lastHistorySignature = '';
  let historyTracksByView: Record<string, HistoryTrackState> = {};
  let historySuspendCount = 0;
  let unsubscribeFabricHistoryViewSwitch: (() => void) | null = null;

  const getHistoryViewKey = (_viewId?: string | null): string => DOCUMENT_HISTORY_VIEW_ID;

  const getActiveHistoryCanvasViewId = (): string => {
    try {
      return String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
    } catch {
      return '';
    }
  };

  const createEmptyHistoryTrack = (): HistoryTrackState => ({
    stack: [],
    index: -1,
    initial: null,
    lastSignature: '',
  });
  const ensureHistoryTrack = (viewId?: string | null): HistoryTrackState => {
    const key = getHistoryViewKey(viewId);
    if (!historyTracksByView[key]) historyTracksByView[key] = createEmptyHistoryTrack();
    return historyTracksByView[key];
  };

  const syncActiveHistoryVarsFromTrack = (track: HistoryTrackState) => {
    historyStack = track.stack;
    historyIndex = track.index;
    initialSnapshot = track.initial;
    lastHistorySignature = track.lastSignature;
  };

  const persistActiveHistoryVarsToTrack = (track: HistoryTrackState) => {
    track.stack = historyStack;
    track.index = historyIndex;
    track.initial = initialSnapshot;
    track.lastSignature = lastHistorySignature;
  };

  const getHistoryTrack = (viewId?: string | null): HistoryTrackState => {
    const track = ensureHistoryTrack(viewId);
    syncActiveHistoryVarsFromTrack(track);
    return track;
  };

  const getHistoryInfoForView = (viewId?: string | null) => {
    const track = ensureHistoryTrack(viewId);
    const index = Number(track.index ?? -1);
    const length = Array.isArray(track.stack) ? track.stack.length : 0;
    return {
      engine: 'fabric',
      scope: 'document',
      viewId: DOCUMENT_HISTORY_VIEW_ID,
      index,
      length,
      canUndo: index > 0,
      canRedo: index >= 0 && index < length - 1,
      maxSnapshots: MAX_HISTORY_SNAPSHOTS,
    };
  };

  const exportFabricHistoryTrack = (viewId?: string | null) => {
    const key = getHistoryViewKey(viewId);
    const track = ensureHistoryTrack(key);
    return {
      engine: 'fabric',
      scope: 'document',
      viewId: DOCUMENT_HISTORY_VIEW_ID,
      index: Number(track.index ?? -1),
      snapshots: (Array.isArray(track.stack) ? track.stack : []).map((snapshot: any) => stripHistoryTracksFromSnapshot(snapshot)),
      lastSignature: String(track.lastSignature ?? ''),
      maxSnapshots: MAX_HISTORY_SNAPSHOTS,
    } as any;
  };

  const importFabricHistoryTrack = (viewId: string, input: any): boolean => {
    const key = getHistoryViewKey(viewId);
    const rawSnapshots = Array.isArray(input?.snapshots) ? input.snapshots.filter((snapshot: any) => snapshot && typeof snapshot === 'object').map((snapshot: any) => stripHistoryTracksFromSnapshot(snapshot)) : [];
    const snapshots = rawSnapshots.length > MAX_HISTORY_SNAPSHOTS
      ? [rawSnapshots[0], ...rawSnapshots.slice(-(MAX_HISTORY_SNAPSHOTS - 1))]
      : rawSnapshots;
    if (!key || snapshots.length === 0) {
      asukaHistoryDebug('designer.fabricHistory.import.skip.empty', { viewId: key, incoming: summarizeHistoryTrack(input) });
      return false;
    }
    const maxIndex = Math.max(0, snapshots.length - 1);
    const index = Math.max(0, Math.min(maxIndex, Number(input?.index ?? maxIndex) || 0));
    const track = ensureHistoryTrack(key);
    track.stack = cloneJson(snapshots);
    track.index = index;
    track.initial = cloneJson(snapshots[0] ?? null);
    track.lastSignature = String(input?.lastSignature ?? '') || (() => { try { return getHistorySnapshotSignatureForView(snapshots[index], key); } catch { return ''; } })();
    if (getHistoryViewKey() === key) syncActiveHistoryVarsFromTrack(track);
    asukaHistoryDebug('designer.fabricHistory.import.done', { viewId: key, imported: summarizeHistoryTrack(exportFabricHistoryTrack(key)), activeViewId: getHistoryViewKey() });
    notifyChange();
    return true;
  };

  const persistFabricHistoryTrackToStore = (
    viewId?: string | null,
    reason = 'persist',
    options: { allowShrink?: boolean } = {},
  ) => {
    const key = getHistoryViewKey(viewId);
    try {
      const track: any = exportFabricHistoryTrack(key);
      if (options.allowShrink === true) track.__asukaAllowHistoryShrink = true;
      asukaHistoryDebug('designer.fabricHistory.persistToStore', { reason, viewId: key, track: summarizeHistoryTrack(track), info: summarizeHistoryInfo(getHistoryInfoForView(key)) });
      (useAsukaStore.getState() as any)?.setDocumentFabricHistoryForView?.(key, track);
    } catch (error) {
      asukaHistoryDebug('designer.fabricHistory.persistToStore.error', { reason, viewId: key, error: String((error as any)?.message ?? error) });
    }
  };

  const isHistoryMuted = () => historySuspendCount > 0;
  const withHistoryMuted = async <T>(fn: () => Promise<T> | T): Promise<T> => {
    historySuspendCount += 1;
    try {
      return await fn();
    } finally {
      historySuspendCount = Math.max(0, historySuspendCount - 1);
    }
  };

  const isHistoryRelevantObject = (obj: any): boolean => {
    if (!obj) return false;
    // Fixed-slot indicators are runtime helper visuals. They are rebuilt from
    // fixedSlots state and must never create their own history entries; otherwise
    // removing/restoring one marker can desync undo/redo from the real user action.
    if ((obj as any)._asukaFixedSlotIsIndicator) return false;
    const role = String((obj as any)._asukaRole ?? '').trim();
    if ((obj as any)._asukaElementIsInstance || (obj as any)._asukaElementName) return true;
    if (!role) return true;
    if (role === 'user') return true;
    if (role === 'drawingZone') return false;
    if (role === 'tool' || role === 'drawingAreaDraft' || role === 'clipPath' || role === 'snapVertical' || role === 'snapHorizontal' || role === 'shapePreview') return false;
    if (role.startsWith('drawingZone')) return false;
    return false;
  };

  const changeListeners = new Set<() => void>();
  const notifyChange = () => {
    try { syncDocumentHistoryInfo(); } catch {}
    for (const fn of Array.from(changeListeners)) {
      try { fn(); } catch {}
    }
  };

  const cloneJson = <T,>(value: T): T => {
    try { return JSON.parse(JSON.stringify(value)); } catch { return value; }
  };

  const perfNow = () => {
    try { return (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now(); } catch { return Date.now(); }
  };
  const roundPerf = (value: number) => Math.round(Number(value || 0) * 100) / 100;

  const isHeavy3DHistoryDataUrl = (value: unknown): boolean => {
    const raw = String(value ?? '').trim();
    if (!raw) return false;
    if (/^data:(?:model\/|application\/(?:octet-stream|gltf|glb|x-binary))/i.test(raw)) return true;
    return /^data:/i.test(raw) && raw.length > 524_288;
  };

  const compact3DModelForFabricHistory = (model: any): any => {
    if (!model || typeof model !== 'object') return model;
    const url = String(model?.url ?? '').trim();
    const format = String(model?.format ?? 'glb').trim() || 'glb';
    if (!url) return cloneJson(model);
    if (isHeavy3DHistoryDataUrl(url) || url.length > 4096) {
      return {
        format,
        metadata: {
          ...((model?.metadata && typeof model.metadata === 'object') ? cloneJson(model.metadata) : {}),
          asukaHistoryModelReference: true,
          urlKind: /^data:/i.test(url) ? 'data-url' : 'long-url',
          urlLength: url.length,
        },
      };
    }
    return cloneJson(model);
  };

  const compact3DMaterialForFabricHistory = (material: any): any => {
    if (!material || typeof material !== 'object') return material;
    const out: any = {};
    for (const key of Object.keys(material)) {
      const value = material[key];
      if (value == null) continue;
      if (/^(native|runtime|file|files|imageElement|canvas|texture|mesh|material)$/i.test(key)) continue;
      if (typeof value === 'string') {
        if (isHeavy3DHistoryDataUrl(value) || value.length > 524_288) continue;
        out[key] = value;
        continue;
      }
      if (typeof value === 'number' || typeof value === 'boolean') {
        out[key] = value;
        continue;
      }
      if (Array.isArray(value)) {
        if (value.length <= 64) out[key] = cloneJson(value);
        continue;
      }
      if (typeof value === 'object') {
        try {
          const serialized = JSON.stringify(value);
          if (serialized.length <= 8192 && !serialized.match(/data:(?:model\/|application\/(?:octet-stream|gltf|glb|x-binary))/i)) out[key] = cloneJson(value);
        } catch {}
      }
    }
    return out;
  };

  const compactProduct3DConfigForFabricHistory = (config: any): any => {
    if (!config || typeof config !== 'object') return config;
    const out: any = {};
    for (const key of Object.keys(config)) {
      const value = config[key];
      if (value == null) continue;
      if (/^(native|runtime|file|files|blob|data|mesh|materialObject)$/i.test(key)) continue;
      if (key === 'model') {
        out.model = compact3DModelForFabricHistory(value);
        continue;
      }
      if (key === 'materials' && value && typeof value === 'object') {
        const materials: any = {};
        for (const [materialKey, material] of Object.entries(value)) {
          materials[String(materialKey)] = compact3DMaterialForFabricHistory(material);
        }
        out.materials = materials;
        continue;
      }
      if (key === 'parts' || key === 'printAreas') {
        if (Array.isArray(value)) out[key] = cloneJson(value);
        continue;
      }
      if (typeof value === 'string') {
        if (isHeavy3DHistoryDataUrl(value) || value.length > 524_288) continue;
        out[key] = value;
        continue;
      }
      if (typeof value === 'number' || typeof value === 'boolean') {
        out[key] = value;
        continue;
      }
      if (Array.isArray(value)) {
        if (value.length <= 256) out[key] = cloneJson(value);
        continue;
      }
      if (typeof value === 'object') {
        try {
          const serialized = JSON.stringify(value);
          if (serialized.length <= 16384 && !serialized.match(/data:(?:model\/|application\/(?:octet-stream|gltf|glb|x-binary))/i)) out[key] = cloneJson(value);
        } catch {}
      }
    }
    return out;
  };

  const compactElementVariationForFabricHistory = (variation: any): any => {
    if (!variation || typeof variation !== 'object') return variation;
    const out: any = {};
    for (const key of Object.keys(variation)) {
      const value = variation[key];
      if (value == null) continue;
      if (key === 'variations') {
        out.variations = Array.isArray(value) ? value.map(compactElementVariationForFabricHistory) : [];
        continue;
      }
      if (key === 'product3d') {
        out.product3d = compactProduct3DConfigForFabricHistory(value);
        continue;
      }
      if ((key === 'image' || key === 'icon') && typeof value === 'string') {
        if (!isHeavy3DHistoryDataUrl(value) && value.length <= 524_288) out[key] = value;
        continue;
      }
      if (typeof value === 'string') {
        if (isHeavy3DHistoryDataUrl(value) || value.length > 524_288) continue;
        out[key] = value;
        continue;
      }
      if (typeof value === 'number' || typeof value === 'boolean') {
        out[key] = value;
        continue;
      }
      if (typeof value === 'object') {
        try {
          const serialized = JSON.stringify(value);
          if (serialized.length <= 16384 && !serialized.match(/data:(?:model\/|application\/(?:octet-stream|gltf|glb|x-binary))/i)) out[key] = cloneJson(value);
        } catch {}
      }
    }
    return out;
  };

  const compactElementForFabricHistory = (element: any): any => {
    if (!element || typeof element !== 'object') return element;
    const isProduct3D = String(element?.type ?? '').trim() === 'product-3d' || !!element?.product3d;
    if (!isProduct3D) return cloneJson(element);
    const out: any = {};
    for (const key of Object.keys(element)) {
      const value = element[key];
      if (value == null) continue;
      if (key === 'product3d') {
        out.product3d = compactProduct3DConfigForFabricHistory(value);
        continue;
      }
      if (key === 'variations') {
        out.variations = Array.isArray(value) ? value.map(compactElementVariationForFabricHistory) : [];
        continue;
      }
      if (/^(native|runtime|file|files|blob|data)$/i.test(key)) continue;
      if (typeof value === 'string') {
        if (isHeavy3DHistoryDataUrl(value) || value.length > 524_288) continue;
        out[key] = value;
        continue;
      }
      if (typeof value === 'number' || typeof value === 'boolean') {
        out[key] = value;
        continue;
      }
      if (Array.isArray(value)) {
        if (value.length <= 256) out[key] = cloneJson(value);
        continue;
      }
      if (typeof value === 'object') {
        try {
          const serialized = JSON.stringify(value);
          if (serialized.length <= 16384 && !serialized.match(/data:(?:model\/|application\/(?:octet-stream|gltf|glb|x-binary))/i)) out[key] = cloneJson(value);
        } catch {}
      }
    }
    return out;
  };

  const compactElementsForFabricHistory = (elements: any): any[] => (Array.isArray(elements) ? elements : []).map(compactElementForFabricHistory);

  const hasUsable3DModel = (value: any): boolean => Boolean(String(value?.model?.url ?? '').trim());

  const mergeProduct3DPayloadFromLiveCatalog = (restored: any, live: any): any => {
    if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return restored;
    const next: any = cloneJson(restored);
    if (!hasUsable3DModel(next?.product3d) && hasUsable3DModel(live?.product3d)) {
      next.product3d = { ...(next.product3d ?? {}), ...(live.product3d ?? {}) };
    }
    const mergeVariations = (restoredList: any[], liveList: any[]): any[] => (Array.isArray(restoredList) ? restoredList : []).map((variation: any) => {
      const match = (Array.isArray(liveList) ? liveList : []).find((candidate: any) => String(candidate?.id ?? '').trim() === String(variation?.id ?? '').trim() || String(candidate?.name ?? '').trim() === String(variation?.name ?? '').trim());
      if (!match) return variation;
      const merged: any = cloneJson(variation);
      if (!hasUsable3DModel(merged?.product3d) && hasUsable3DModel(match?.product3d)) merged.product3d = { ...(merged.product3d ?? {}), ...(match.product3d ?? {}) };
      if (Array.isArray(merged.variations)) merged.variations = mergeVariations(merged.variations, Array.isArray(match?.variations) ? match.variations : []);
      return merged;
    });
    if (Array.isArray(next.variations)) next.variations = mergeVariations(next.variations, Array.isArray(live?.variations) ? live.variations : []);
    return next;
  };

  const mergeRestoredElementsWithLive3DPayload = (restoredElements: any): any[] => {
    const restored = Array.isArray(restoredElements) ? restoredElements : [];
    let liveElements: any[] = [];
    try { liveElements = Array.isArray((useAsukaStore.getState() as any)?.elements) ? (useAsukaStore.getState() as any).elements : []; } catch {}
    return restored.map((element: any) => {
      const isProduct3D = String(element?.type ?? '').trim() === 'product-3d' || !!element?.product3d;
      if (!isProduct3D) return element;
      const match = liveElements.find((candidate: any) => String(candidate?.id ?? '').trim() === String(element?.id ?? '').trim() || String(candidate?.name ?? '').trim() === String(element?.name ?? '').trim());
      return match ? mergeProduct3DPayloadFromLiveCatalog(element, match) : element;
    });
  };
  const countSnapshotObjects = (snapshot: any, viewId?: string | null): number => {
    try {
      const byView = snapshot?.objectsByView && typeof snapshot.objectsByView === 'object' ? snapshot.objectsByView : {};
      const key = String(viewId ?? snapshot?.activeViewId ?? '').trim();
      if (key && Array.isArray(byView?.[key])) return byView[key].length;
      return Object.values(byView).reduce((total: number, list: any) => total + (Array.isArray(list) ? list.length : 0), 0);
    } catch { return 0; }
  };
  const stripHistoryTracksFromSnapshot = <T,>(snapshot: T): T => {
    if (!snapshot || typeof snapshot !== 'object') return snapshot;
    const next: any = cloneJson(snapshot as any);
    // Critical perf guard: history snapshots must never contain the history tracks themselves.
    // Otherwise each new Fabric snapshot embeds the previous stack, then the next snapshot embeds
    // that bigger stack again. The cost grows with every drag/release even in a pure 2D view.
    try { delete next.history3dByView; } catch {}
    try { delete next.historyFabricByView; } catch {}
    try { delete next.document3dHistoryByView; } catch {}
    try { delete next.documentFabricHistoryByView; } catch {}
    // Fabric/document history is responsible for 2D object state across views. 3D scenes
    // and 3D tracks have their own view-scoped persistence/history. Keeping full 3D
    // scene payloads here duplicates GLB/data-url state inside every Fabric snapshot
    // and can exhaust browser memory when switching 3D -> 2D after adding products.
    try { delete next.scenes3dByView; } catch {}
    try { delete next.document3dScenesByView; } catch {}
    return next as T;
  };
  const summarizeFabricSnapshotForPerf = (snapshot: any, viewId?: string | null) => {
    if (!isAsuka2DPerfDebugEnabled()) return null;
    const key = getHistoryViewKey(viewId ?? snapshot?.activeViewId ?? null);
    return {
      viewId: key,
      activeViewId: snapshot?.activeViewId ?? null,
      activeRenderEngineKind: snapshot?.activeRenderEngineKind ?? null,
      objectsInView: countSnapshotObjects(snapshot, key),
      totalObjectViews: snapshot?.objectsByView && typeof snapshot.objectsByView === 'object' ? Object.keys(snapshot.objectsByView).length : 0,
      drawingAreaViews: snapshot?.drawingAreasByView && typeof snapshot.drawingAreasByView === 'object' ? Object.keys(snapshot.drawingAreasByView).length : 0,
      hasHistory3dByView: Boolean(snapshot?.history3dByView && Object.keys(snapshot.history3dByView ?? {}).length),
      hasHistoryFabricByView: Boolean(snapshot?.historyFabricByView && Object.keys(snapshot.historyFabricByView ?? {}).length),
      payloadBytes: getAsukaDebugPayloadSize(snapshot),
    };
  };

  const disposeFabricObjectDeep = (obj: any, seen = new Set<any>()) => {
    if (!obj || seen.has(obj)) return;
    seen.add(obj);
    try {
      const children = typeof obj.getObjects === 'function'
        ? obj.getObjects()
        : Array.isArray(obj._objects)
          ? obj._objects
          : [];
      if (Array.isArray(children)) {
        for (const child of children.slice()) disposeFabricObjectDeep(child, seen);
      }
    } catch {}
    try { if (obj.clipPath) disposeFabricObjectDeep(obj.clipPath, seen); } catch {}
    try { obj.off?.(); } catch {}
    try { obj.dispose?.(); } catch {}
    try { obj._cacheCanvas = undefined; obj._cacheContext = undefined; } catch {}
  };

  const getDocumentObjectsState = (): Record<string, any[]> => {
    try {
      const state = useAsukaStore.getState() as any;
      return (state?.documentObjectsByView && typeof state.documentObjectsByView === 'object')
        ? state.documentObjectsByView
        : {};
    } catch {
      return {};
    }
  };


  const getDocumentObjectsForView = (viewId?: string | null): any[] => {
    const targetViewId = String(viewId ?? (useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
    if (!targetViewId) return [];
    const byView = getDocumentObjectsState();
    return Array.isArray(byView[targetViewId]) ? byView[targetViewId] : [];
  };

  const getAllDocumentObjectRecords = (predicate?: (obj: any, viewId: string, index: number) => boolean) => {
    const out: Array<{ viewId: string; index: number; object: any }> = [];
    const byView = getDocumentObjectsState();
    for (const [viewId, objects] of Object.entries(byView)) {
      const list = Array.isArray(objects) ? objects : [];
      list.forEach((obj: any, index: number) => {
        if (!predicate || predicate(obj, String(viewId), index)) out.push({ viewId: String(viewId), index, object: obj });
      });
    }
    return out;
  };

  const getDocumentDrawingAreasByView = () => {
    const state = useAsukaStore.getState() as any;
    const activeViewId = String(state?.activeViewId ?? '').trim();
    const snapshots = exportViewSnapshots();
    const byView: Record<string, any[]> = {};
    for (const [viewId, snapshot] of Object.entries(snapshots ?? {})) {
      const areas = Array.isArray((snapshot as any)?.drawingAreas) ? cloneJson((snapshot as any).drawingAreas) : [];
      byView[String(viewId)] = areas;
    }
    if (activeViewId) byView[activeViewId] = cloneJson(state?.documentDrawingAreas ?? []);
    return byView;
  };

  const readDocumentSnapshot = (options: { includeHistoryTracks?: boolean; include3DScenes?: boolean; reason?: string } = {}) => {
    const perfStart = perfNow();
    const state = useAsukaStore.getState() as any;
    const activeViewId = String(state?.activeViewId ?? '').trim() || null;
    const includeHistoryTracks = options.includeHistoryTracks === true;
    const include3DScenes = options.include3DScenes === true;
    const snapshot: any = {
      activeViewId,
      activeRenderEngineKind: String(state?.activeRenderEngineKind ?? 'fabric') === '3d' ? '3d' : 'fabric',
      elements: cloneJson(compactElementsForFabricHistory(state?.elements ?? [])),
      objectsByView: cloneJson(getDocumentObjectsState()),
      ...(include3DScenes ? { scenes3dByView: cloneJson(state?.document3dScenesByView ?? {}) } : {}),
      drawingAreasByView: cloneJson(getDocumentDrawingAreasByView()),
      selection: cloneJson(state?.documentSelection ?? { activeObjectIds: [], activeDrawingAreaId: null }),
      viewportByView: cloneJson(state?.documentViewportByView ?? {}),
      fixedSlots: cloneJson(state?.fixedSlots ?? []),
      fixedSlotActiveId: state?.fixedSlotActiveId ?? null,
    };

    // PERF FIX: history tracks are not document state. Keeping them inside each
    // Fabric history snapshot makes the next history commit clone/serialize the
    // whole previous stack again. This is exactly the pattern that makes pure 2D
    // dragging get slower after more releases.
    if (includeHistoryTracks) {
      snapshot.history3dByView = cloneJson(state?.document3dHistoryByView ?? {});
      snapshot.historyFabricByView = cloneJson(state?.documentFabricHistoryByView ?? {});
    }

    asuka2DPerfDebug('designer.fabricHistory.readDocumentSnapshot', {
      reason: options.reason ?? 'read',
      includeHistoryTracks,
      durationMs: roundPerf(perfNow() - perfStart),
      snapshot: summarizeFabricSnapshotForPerf(snapshot, activeViewId),
    });
    return snapshot;
  };

  const getHistorySnapshotSignature = (snapshot: any) => {
    try {
      return JSON.stringify({
        activeViewId: snapshot?.activeViewId ?? null,
        activeRenderEngineKind: snapshot?.activeRenderEngineKind ?? null,
        elements: snapshot?.elements ?? [],
        objectsByView: snapshot?.objectsByView ?? {},
        drawingAreasByView: snapshot?.drawingAreasByView ?? {},
        viewportByView: snapshot?.viewportByView ?? {},
        fixedSlots: snapshot?.fixedSlots ?? [],
        fixedSlotActiveId: snapshot?.fixedSlotActiveId ?? null,
      });
    } catch {
      try { return JSON.stringify(snapshot); } catch { return ''; }
    }
  };


  const getHistorySnapshotSignatureForView = (snapshot: any, viewId?: string | null) => {
    const key = getHistoryViewKey(viewId ?? snapshot?.activeViewId ?? null);
    if (key === DOCUMENT_HISTORY_VIEW_ID) return getHistorySnapshotSignature(snapshot);
    try {
      return JSON.stringify({
        activeViewId: key,
        activeRenderEngineKind: snapshot?.activeRenderEngineKind ?? null,
        elements: snapshot?.elements ?? [],
        objects: snapshot?.objectsByView?.[key] ?? [],
        drawingAreas: snapshot?.drawingAreasByView?.[key] ?? [],
        viewport: snapshot?.viewportByView?.[key] ?? null,
        selection: snapshot?.selection ?? null,
        fixedSlots: snapshot?.fixedSlots ?? [],
        fixedSlotActiveId: snapshot?.fixedSlotActiveId ?? null,
      });
    } catch {
      return getHistorySnapshotSignature(snapshot);
    }
  };

  const loadDocumentSnapshot = (snapshot: any) => {
    const snapshotEngineKind = String((snapshot as any)?.activeRenderEngineKind ?? '').trim().toLowerCase();
    const snapshotViewId = String((snapshot as any)?.activeViewId ?? '').trim();
    if (snapshotViewId && snapshotEngineKind !== '3d') {
      try {
        (canvas as any).__asukaSnapshotLoading = true;
        (canvas as any).__asukaRestoringFromState = true;
      } catch {}
    }
    try {
      const byView = (snapshot?.objectsByView && typeof snapshot.objectsByView === 'object') ? snapshot.objectsByView : {};
      Object.values(byView).forEach((objects: any) => hydrateFontsFromSerializedObjects(objects));
    } catch {}
    try {
      if (Array.isArray((snapshot as any)?.elements)) {
        useAsukaStore.setState({ elements: cloneJson(mergeRestoredElementsWithLive3DPayload((snapshot as any).elements)) } as any);
      }
    } catch {}
    try {
      if (Array.isArray((snapshot as any)?.fixedSlots)) {
        useAsukaStore.setState({
          fixedSlots: cloneJson((snapshot as any).fixedSlots),
          fixedSlotActiveId: (snapshot as any).fixedSlotActiveId ?? null,
          fixedSlotsPlacing: null,
        } as any);
      }
    } catch {}
    try {
      const scenes3dByView = ((snapshot as any)?.scenes3dByView && typeof (snapshot as any).scenes3dByView === 'object') ? (snapshot as any).scenes3dByView : null;
      const history3dByView = ((snapshot as any)?.history3dByView && typeof (snapshot as any).history3dByView === 'object') ? (snapshot as any).history3dByView : null;
      const historyFabricByView = ((snapshot as any)?.historyFabricByView && typeof (snapshot as any).historyFabricByView === 'object') ? (snapshot as any).historyFabricByView : null;
      const engineKind = String((snapshot as any)?.activeRenderEngineKind ?? '').trim().toLowerCase();
      const patch: any = {};
      if (scenes3dByView) patch.document3dScenesByView = cloneJson(scenes3dByView);
      if (history3dByView) patch.document3dHistoryByView = cloneJson(history3dByView);
      if (historyFabricByView) patch.documentFabricHistoryByView = cloneJson(historyFabricByView);
      if (engineKind === '3d' || engineKind === 'fabric') patch.activeRenderEngineKind = engineKind;
      if (Object.keys(patch).length) useAsukaStore.setState(patch as any);
    } catch {}
    try {
      const drawingAreasByView = ((snapshot as any)?.drawingAreasByView && typeof (snapshot as any).drawingAreasByView === 'object') ? (snapshot as any).drawingAreasByView : {};
      const activeAreaId = String((snapshot as any)?.selection?.activeDrawingAreaId ?? '').trim() || null;
      const activeViewId = String((snapshot as any)?.activeViewId ?? '').trim();
      for (const [viewId, zones] of Object.entries(drawingAreasByView)) {
        const viewKey = String(viewId);
        setSnapshotDrawingAreasForView(viewKey, Array.isArray(zones) ? zones as any[] : [], viewKey === activeViewId ? activeAreaId : null);
      }
    } catch {}
    try { rendererLifecycleRuntime?.loadDocumentSnapshot?.(snapshot); } catch {}
  };


  const serializeDocumentObject = (obj: any) => {
    if (!obj) return null;
    try { return obj.toObject?.(EXTRA_SERIALIZE_PROPS as any) ?? null; } catch { return null; }
  };

  const setDocumentObjectsForViewState = (viewId: string, objects: any[]) => {
    const targetViewId = String(viewId ?? '').trim();
    if (!targetViewId) return;
    const cloned = cloneJson(Array.isArray(objects) ? objects : []);
    try { useAsukaStore.getState?.().setDocumentObjectsForView?.(targetViewId, cloned); } catch {
      try { useAsukaStore.setState({ documentObjectsByView: { ...(useAsukaStore.getState() as any).documentObjectsByView, [targetViewId]: cloned } } as any); } catch {}
    }
  };

  const getActiveDocumentViewId = (): string => String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();

  const findDocumentObjectRecord = (objectId: string, viewId?: string | null) => {
    const targetViewId = String(viewId ?? getActiveDocumentViewId()).trim();
    if (!targetViewId || !objectId) return null;
    const objects = Array.isArray(getDocumentObjectsState()[targetViewId]) ? getDocumentObjectsState()[targetViewId] : [];
    const index = objects.findIndex((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '') === String(objectId));
    if (index < 0) return null;
    return { viewId: targetViewId, index, object: objects[index], objects };
  };

  const getRuntimeObjectById = (objectId: string) => {
    if (!objectId) return null as any;
    try {
      const objs = canvas.getObjects?.() ?? [];
      return objs.find((o: any) => ensureObjectId(o) === objectId) ?? null;
    } catch {
      return null as any;
    }
  };

  const patchDocumentObject = (objectId: string, patch: Record<string, any>, options?: { viewId?: string | null; rebuild?: boolean }) => {
    if (!objectId || !patch || typeof patch !== 'object') return null as any;
    const record = findDocumentObjectRecord(objectId, options?.viewId ?? null);
    if (!record) return null as any;
    const next = record.objects.slice();
    next[record.index] = { ...(next[record.index] ?? {}), ...cloneJson(patch), _asukaObjectId: String(objectId) };
    setDocumentObjectsForViewState(record.viewId, next);
    if (options?.rebuild !== false) rebuildViewFromDocumentState(record.viewId);
    return getRuntimeObjectById(objectId) ?? next[record.index];
  };

  const replaceDocumentObjectFromRuntime = (objectId: string, runtimeObject: any, options?: { viewId?: string | null; rebuild?: boolean }) => {
    if (!objectId || !runtimeObject) return null as any;
    const record = findDocumentObjectRecord(objectId, options?.viewId ?? null);
    if (!record) return null as any;
    const serialized = serializeDocumentObject(runtimeObject);
    if (!serialized) return null as any;
    const next = record.objects.slice();
    next[record.index] = { ...cloneJson(serialized), _asukaObjectId: String(objectId) };
    setDocumentObjectsForViewState(record.viewId, next);
    if (options?.rebuild !== false) rebuildViewFromDocumentState(record.viewId);
    return getRuntimeObjectById(objectId) ?? next[record.index];
  };

  const rebuildViewFromDocumentState = (targetViewId?: string | null) => {
    try { rendererLifecycleRuntime?.rebuildViewFromDocumentState?.(targetViewId); } catch {}
  };

  const syncDocumentObjectsForView = (viewId?: string | null) => {
    const perfStart = perfNow();
    const targetViewId = String(viewId ?? useAsukaStore.getState?.().activeViewId ?? '').trim();
    if (!targetViewId) return;
    try {
      const rawObjects = (canvas.getObjects?.() ?? []);
      const filteredObjects = rawObjects.filter((obj: any) => {
        const role = String(obj?._asukaRole ?? '').trim();
        if (role === 'drawingZone' || role === 'tool' || role === 'drawingAreaDraft' || role === 'clipPath' || role === 'snapVertical' || role === 'snapHorizontal' || role === 'shapePreview') return false;
        if (role.startsWith('drawingZone')) return false;
        if (obj?._asukaFixedSlotIsIndicator) return false;
        return true;
      });
      const serializeStart = perfNow();
      const objects = filteredObjects
        .map((obj: any) => serializeDocumentObject(obj))
        .filter(Boolean);
      const serializeMs = roundPerf(perfNow() - serializeStart);
      const storeStart = perfNow();
      setDocumentObjectsForViewState(targetViewId, objects as any[]);
      const storeMs = roundPerf(perfNow() - storeStart);
      asuka2DPerfDebug('designer.fabric.syncDocumentObjectsForView', {
        viewId: targetViewId,
        durationMs: roundPerf(perfNow() - perfStart),
        serializeMs,
        storeMs,
        rawCount: rawObjects.length,
        filteredCount: filteredObjects.length,
        payloadBytes: getAsukaDebugPayloadSize(objects),
      });
    } catch (error) {
      asuka2DPerfDebug('designer.fabric.syncDocumentObjectsForView.error', {
        viewId: targetViewId,
        durationMs: roundPerf(perfNow() - perfStart),
        error: String((error as any)?.message ?? error),
      });
    }
  };

  const syncDocumentHistoryInfo = (viewId?: string | null) => {
    const info = getHistoryInfoForView(viewId);
    try {
      useAsukaStore.getState?.().setDocumentHistoryInfo?.(info as any);
    } catch {
      try {
        useAsukaStore.setState({ documentHistoryInfo: info } as any);
      } catch {}
    }
  };

const rebindSpecialObjectsImpl = () => {
    rebuildRuntimeObjectIndex();
    const objects = runtimeObjectIndex.values();
    for (const obj of objects) {
      const role = (obj as any)._asukaRole;
      // Runtime helpers are visual/interaction infrastructure, not editable
      // layers. Reapplying user controls here makes overlays such as the
      // selected drawing-area outline intercept pointer hits above real layers.
      if (isRuntimeHelperRole(role)) continue;

      if (isFabricElementObject(obj)) {
        try {
          const store = useAsukaStore.getState() as any;
          const elementId = String((obj as any)._asukaElementId ?? '').trim();
          const elementName = String((obj as any)._asukaElementName ?? '').trim();
          const element = resolveElementRecord(store, elementId || elementName, elementName);
          if (element) {
            if ((element as any).id) (obj as any)._asukaElementId = String((element as any).id);
            if ((element as any).familyId || (element as any).id) {
              (obj as any)._asukaElementFamilyId = String((element as any).familyId ?? (element as any).id);
            }
            if ((element as any).name) (obj as any)._asukaElementName = String((element as any).name);
            configureFabricElementInteraction(obj, {
              selectable: Boolean((element as any).selectable === true),
              evented: Boolean((element as any).evented !== false),
            });
          } else {
            configureFabricElementInteraction(obj);
          }
        } catch {}
      }

      const fixedSlotId = String((obj as any)?._asukaFixedSlotId ?? '').trim();
      if (fixedSlotId) {
        const slot = findSlotById(fixedSlotId);
        if (slot) {
          try {
            (obj as any)._asukaFixedSlotType = String(slot.type ?? (obj as any)._asukaFixedSlotType ?? 'text');
            (obj as any)._asukaFixedSlotConstraints = slot.constraints ?? {};
            applyFixedConstraintsToObject(obj, slot.constraints ?? {});
            if ((obj as any)._asukaFixedSlotIsIndicator) {
              obj.selectable = false;
              obj.evented = true;
              (obj as any).hasControls = false;
              (obj as any).hasBorders = false;
              (obj as any).hoverCursor = 'pointer';
              (obj as any).excludeFromExport = true;
            }
          } catch {}
        }
      }

      (obj as any).itemGroup = (obj as any).itemGroup ?? (role && role !== 'user' ? 'Tools' : 'Other');

      if (!isFabricElementObject(obj)) {
        asukaApplyControls(obj);
      }
      if (isListenerEnabled('constraints') && !(obj as any)._asukaIgnoreDrawingZones) {
        if (!(obj as any)._asukaZoneId) (obj as any)._asukaZoneId = activeDrawingZoneId ?? getZone().id;
        applyClipForObject(obj);
        const status = classifyObjectPosition(obj, getZoneForObject(obj));
        if (isListenerEnabled('adjustBoundingBoxColors') || isListenerEnabled('boundingBoxesBorders')) {
          updateObjectStrokeForZone(obj, status);
        }
        if (isListenerEnabled('preventBoundingBoxExit')) {
          rememberLastGoodPosition(obj, status);
        }
      } else {
        try { obj.clipPath = null; } catch {}
      }
    }
  };

  const saveSnapshot = (options: { force?: boolean; skipCanvasSync?: boolean; reason?: string; viewId?: string | null } = {}) => {
    const savePerfStart = perfNow();
    const perfEnabled = isAsuka2DPerfDebugEnabled();
    const timings: Record<string, number> = {};
    const mark = (name: string, startedAt: number) => { if (perfEnabled) timings[name] = roundPerf(perfNow() - startedAt); };
    if (!options.force && isHistoryMuted()) {
      asuka2DPerfDebug('designer.fabricHistory.saveSnapshot.skip.muted', { reason: options.reason ?? null });
      return;
    }
    const historyViewId = getHistoryViewKey(options.viewId);
    const activeTrack = getHistoryTrack(historyViewId);
    try {
      const canvasIsRebuilding = (() => {
        try {
          return Boolean(
            (canvas as any).__asukaSnapshotLoading ||
            (canvas as any).__asukaViewRebuilding ||
            (canvas as any).__asukaStateImporting ||
            (canvas as any).__asukaRestoringFromState,
          );
        } catch { return false; }
      })();
      const activeCanvasViewId = getActiveHistoryCanvasViewId();
      if (!options.skipCanvasSync && !canvasIsRebuilding && activeCanvasViewId) {
        const t = perfNow();
        try { syncDocumentObjectsForView(activeCanvasViewId); } catch {}
        mark('syncDocumentObjectsForViewMs', t);
      }

      const readStart = perfNow();
      const snapshot = stripHistoryTracksFromSnapshot(readDocumentSnapshot({ reason: options.reason ?? 'saveSnapshot' }));
      mark('readDocumentSnapshotMs', readStart);

      const sigStart = perfNow();
      const signature = getHistorySnapshotSignatureForView(snapshot, historyViewId);
      mark('signatureMs', sigStart);

      if (!initialSnapshot) {
        const cloneStart = perfNow();
        initialSnapshot = stripHistoryTracksFromSnapshot(snapshot);
        mark('cloneInitialMs', cloneStart);
      }

      // Ignore consecutive duplicates only. A -> B -> A must remain valid.
      if (signature === lastHistorySignature && historyStack.length > 0) {
        asuka2DPerfDebug('designer.fabricHistory.saveSnapshot.skip.duplicate', {
          reason: options.reason ?? null,
          viewId: historyViewId,
          durationMs: roundPerf(perfNow() - savePerfStart),
          timings,
          stackLength: historyStack.length,
          index: historyIndex,
          snapshot: summarizeFabricSnapshotForPerf(snapshot, historyViewId),
        });
        return;
      }

      // Trim future snapshots if user changed something after undo.
      if (historyIndex < historyStack.length - 1) {
        historyStack = historyStack.slice(0, historyIndex + 1);
      }

      if (historyStack.length === 0) {
        const cloneStart = perfNow();
        historyStack.push(stripHistoryTracksFromSnapshot(snapshot));
        mark('clonePushMs', cloneStart);
        historyIndex = 0;
        lastHistorySignature = signature;
        persistActiveHistoryVarsToTrack(activeTrack);
        const persistStart = perfNow();
        persistFabricHistoryTrackToStore(historyViewId, options.reason ?? 'saveSnapshot:first');
        mark('persistTrackToStoreMs', persistStart);
        const notifyStart = perfNow();
        notifyChange();
        mark('notifyChangeMs', notifyStart);
        asuka2DPerfDebug('designer.fabricHistory.saveSnapshot.done', {
          reason: options.reason ?? 'saveSnapshot:first',
          viewId: historyViewId,
          durationMs: roundPerf(perfNow() - savePerfStart),
          timings,
          stackLength: historyStack.length,
          index: historyIndex,
          snapshot: summarizeFabricSnapshotForPerf(snapshot, historyViewId),
          trackPayloadBytes: perfEnabled ? getAsukaDebugPayloadSize(exportFabricHistoryTrack(historyViewId)) : null,
        });
        return;
      }

      const cloneStart = perfNow();
      historyStack.push(stripHistoryTracksFromSnapshot(snapshot));
      mark('clonePushMs', cloneStart);

      // Keep at most MAX_HISTORY_SNAPSHOTS entries while preserving the initial snapshot.
      if (historyStack.length > MAX_HISTORY_SNAPSHOTS) {
        const overflow = historyStack.length - MAX_HISTORY_SNAPSHOTS;
        historyStack.splice(1, overflow);
      }

      historyIndex = historyStack.length - 1;
      lastHistorySignature = signature;
      persistActiveHistoryVarsToTrack(activeTrack);
      const persistStart = perfNow();
      persistFabricHistoryTrackToStore(historyViewId, options.reason ?? 'saveSnapshot');
      mark('persistTrackToStoreMs', persistStart);
      const notifyStart = perfNow();
      notifyChange();
      mark('notifyChangeMs', notifyStart);
      asuka2DPerfDebug('designer.fabricHistory.saveSnapshot.done', {
        reason: options.reason ?? 'saveSnapshot',
        viewId: historyViewId,
        durationMs: roundPerf(perfNow() - savePerfStart),
        timings,
        stackLength: historyStack.length,
        index: historyIndex,
        snapshot: summarizeFabricSnapshotForPerf(snapshot, historyViewId),
        trackPayloadBytes: perfEnabled ? getAsukaDebugPayloadSize(exportFabricHistoryTrack(historyViewId)) : null,
      });
    } catch (error) {
      asuka2DPerfDebug('designer.fabricHistory.saveSnapshot.error', {
        reason: options.reason ?? null,
        viewId: historyViewId,
        durationMs: roundPerf(perfNow() - savePerfStart),
        timings,
        error: String((error as any)?.message ?? error),
      });
    }
  };


  let pendingHistoryCommitRaf: number | null = null;
  let pendingHistoryCommitOptions: { force?: boolean; skipCanvasSync?: boolean; reason?: string; viewId?: string | null } | null = null;
  const cancelPendingHistorySnapshot = (_reason = 'cancel') => {
    try {
      if (pendingHistoryCommitRaf != null) {

        cancelAnimationFrame(pendingHistoryCommitRaf);
      }
    } catch {}
    pendingHistoryCommitRaf = null;
    pendingHistoryCommitOptions = null;
  };

  const scheduleHistorySnapshot = (options: { force?: boolean; skipCanvasSync?: boolean; reason?: string } = {}) => {
    asuka2DPerfDebug('designer.fabricHistory.scheduleSnapshot', { reason: options.reason ?? null, force: Boolean(options.force), skipCanvasSync: Boolean(options.skipCanvasSync), viewId: getHistoryViewKey((options as any).viewId) });
    if (isHistoryMuted()) {

      return;
    }
    cancelPendingHistorySnapshot('schedule-new');
    const scheduledOptions = { ...options, viewId: getHistoryViewKey((options as any).viewId) };
    pendingHistoryCommitOptions = scheduledOptions;

    pendingHistoryCommitRaf = requestAnimationFrame(() => {
      const runOptions = pendingHistoryCommitOptions ?? scheduledOptions;
      pendingHistoryCommitRaf = null;
      pendingHistoryCommitOptions = null;
      // A pending history commit can fire while undo/redo is rebuilding the
      // canvas. Never persist those transient rebuild states.
      if (isHistoryMuted()) {

        return;
      }
      const scheduledViewId = String((runOptions as any)?.viewId ?? '').trim();
      const currentViewId = getHistoryViewKey();
      if (scheduledViewId && currentViewId !== scheduledViewId) {
        return;
      }

      saveSnapshot(runOptions);
    });
  };

  const flushPendingHistorySnapshot = (options: { force?: boolean; skipCanvasSync?: boolean; reason?: string } = {}) => {
    if (isHistoryMuted()) {

      return;
    }
    // Deletions can happen before the previous add/change RAF commit has fired.
    // If we only schedule the post-delete snapshot, `scheduleHistorySnapshot()`
    // cancels that pending commit and the history jumps from "before add" to
    // "after delete". Those states are identical, so the deletion appears to be
    // missing from history. Flush the current canvas/document state first so the
    // subsequent removal has a real previous snapshot to undo to.

    cancelPendingHistorySnapshot('flush');
    saveSnapshot({ force: options.force ?? true, skipCanvasSync: options.skipCanvasSync, reason: options.reason ?? 'flush' });
  };

  const commitHistorySnapshotNow = (options: { force?: boolean; skipCanvasSync?: boolean; reason?: string } = {}) => {
    if (isHistoryMuted()) {

      return;
    }
    cancelPendingHistorySnapshot('commit-now');
    saveSnapshot({ force: options.force ?? true, skipCanvasSync: options.skipCanvasSync, reason: options.reason ?? 'commit-now' });
  };

  const captureHistoryBaseline = (options: { skipCanvasSync?: boolean; reason?: string; viewId?: string | null; force?: boolean } = {}) => {
    const historyViewId = getHistoryViewKey(options.viewId);
    const activeTrack = getHistoryTrack(historyViewId);
    try {
      // Do not let delayed boot/view-switch baselines erase a real per-view
      // history stack. This was the source of the "history is correct for a
      // split second, then resets" symptom: a post-switch baseline captured
      // the loaded canvas and replaced the existing track with a single snapshot.
      if (!options.force && Array.isArray(activeTrack.stack) && activeTrack.stack.length > 0) {
        asukaHistoryDebug('designer.fabricHistory.captureBaseline.skipExistingTrack', {
          viewId: historyViewId,
          reason: options.reason ?? 'capture-history-baseline',
          track: summarizeHistoryTrack(exportFabricHistoryTrack(historyViewId)),
        });
        syncActiveHistoryVarsFromTrack(activeTrack);
        persistFabricHistoryTrackToStore(historyViewId, options.reason ?? 'capture-baseline-skip-existing');
        notifyChange();
        return;
      }
      cancelPendingHistorySnapshot(options.reason ?? 'capture-history-baseline');
      const canvasIsRebuilding = (() => {
        try {
          return Boolean(
            (canvas as any).__asukaSnapshotLoading ||
            (canvas as any).__asukaViewRebuilding ||
            (canvas as any).__asukaStateImporting ||
            (canvas as any).__asukaRestoringFromState,
          );
        } catch { return false; }
      })();
      const activeCanvasViewId = getActiveHistoryCanvasViewId();
      if (!options.skipCanvasSync && !canvasIsRebuilding && activeCanvasViewId) {
        try { syncDocumentObjectsForView(activeCanvasViewId); } catch {}
      }
      const snapshot = stripHistoryTracksFromSnapshot(readDocumentSnapshot());
      const signature = getHistorySnapshotSignatureForView(snapshot, historyViewId);
      initialSnapshot = cloneJson(snapshot);
      historyStack = [cloneJson(snapshot)];
      historyIndex = 0;
      lastHistorySignature = signature;
      persistActiveHistoryVarsToTrack(activeTrack);
      persistFabricHistoryTrackToStore(
        historyViewId,
        options.reason ?? 'capture-baseline',
        { allowShrink: true },
      );
      notifyChange();
    } catch {}
  };

  const ensureHistoryBaselineBeforeMutation = (reason = 'before-mutation') => {
    if (isHistoryMuted()) return;

    const historyViewId = getHistoryViewKey();
    const activeTrack = getHistoryTrack(historyViewId);

    // A previous mutation may still be waiting for its RAF commit. Persist it
    // before another object is inserted so rapid consecutive adds remain
    // independently undoable.
    if (pendingHistoryCommitRaf != null) {
      flushPendingHistorySnapshot({
        force: true,
        reason: `${reason}:flush-pending`,
      });
      return;
    }

    // Only the untouched, single-snapshot track may be rebased. Once the user
    // has real history, this helper must never rewrite it.
    if (activeTrack.stack.length !== 1 || activeTrack.index !== 0) return;

    const canvasIsRebuilding = (() => {
      try {
        return Boolean(
          (canvas as any).__asukaSnapshotLoading ||
          (canvas as any).__asukaViewRebuilding ||
          (canvas as any).__asukaStateImporting ||
          (canvas as any).__asukaRestoringFromState,
        );
      } catch {
        return false;
      }
    })();
    // During a late Fabric rebuild, the canonical document store already owns
    // the imported product state while the canvas can still be transient or
    // empty. Capture that stable store as the first baseline; only synchronize
    // from the canvas once rebuilding has completed.
    if (!canvasIsRebuilding) {
      const activeCanvasViewId = getActiveHistoryCanvasViewId();
      if (activeCanvasViewId) {
        try { syncDocumentObjectsForView(activeCanvasViewId); } catch {}
      }
    }

    const snapshot = stripHistoryTracksFromSnapshot(readDocumentSnapshot({ reason }));
    const signature = getHistorySnapshotSignatureForView(snapshot, historyViewId);
    if (signature === activeTrack.lastSignature) return;

    initialSnapshot = cloneJson(snapshot);
    historyStack = [cloneJson(snapshot)];
    historyIndex = 0;
    lastHistorySignature = signature;
    persistActiveHistoryVarsToTrack(activeTrack);
    persistFabricHistoryTrackToStore(historyViewId, reason);
    notifyChange();
  };

  const commitHistoryChange = (options: { force?: boolean; skipCanvasSync?: boolean; reason?: string } = {}) => {
    try { canvas.requestRenderAll?.(); } catch {}
    scheduleHistorySnapshot({ force: options.force ?? true, skipCanvasSync: options.skipCanvasSync, reason: options.reason ?? 'commit-change' });
  };

  const ensureHistoryBaselineForView = (viewId?: string | null, reason = 'view-baseline') => {
    const historyViewId = getHistoryViewKey(viewId);
    const track = ensureHistoryTrack(historyViewId);
    if (track.stack.length > 0) {
      syncDocumentHistoryInfo(historyViewId);
      return;
    }
    const currentViewId = getHistoryViewKey();
    if (currentViewId !== historyViewId) {
      syncDocumentHistoryInfo(historyViewId);
      return;
    }
    captureHistoryBaseline({ viewId: historyViewId, reason });
  };

  const scheduleHistoryInfoSyncForView = (viewId?: string | null, reason = 'view-switch') => {
    const historyViewId = getHistoryViewKey(viewId);
    const run = () => {
      try {
        if (getHistoryViewKey() !== historyViewId) return;
        ensureHistoryBaselineForView(historyViewId, reason);
      } catch {}
    };
    try {
      requestAnimationFrame(() => requestAnimationFrame(run));
    } catch {
      setTimeout(run, 32);
    }
  };

  const buildDocumentScopedHistorySnapshot = (snapshot: any) => {
    const activeViewId = getActiveHistoryCanvasViewId() || String(snapshot?.activeViewId ?? '').trim() || null;
    const activeKind = (() => {
      try {
        const kind = String((useAsukaStore.getState() as any)?.activeRenderEngineKind ?? snapshot?.activeRenderEngineKind ?? 'fabric').trim().toLowerCase();
        return kind === '3d' ? '3d' : 'fabric';
      } catch {
        return String(snapshot?.activeRenderEngineKind ?? 'fabric') === '3d' ? '3d' : 'fabric';
      }
    })();
    return {
      ...snapshot,
      activeViewId,
      activeRenderEngineKind: activeKind,
    };
  };

  const buildViewScopedHistorySnapshot = (snapshot: any, viewId: string) => {
    const current = readDocumentSnapshot();
    const key = getHistoryViewKey(viewId);
    const nextObjectsByView = {
      ...(current?.objectsByView && typeof current.objectsByView === 'object' ? current.objectsByView : {}),
    } as Record<string, any[]>;
    const sourceObjectsByView = (snapshot?.objectsByView && typeof snapshot.objectsByView === 'object') ? snapshot.objectsByView : {};
    nextObjectsByView[key] = cloneJson(Array.isArray(sourceObjectsByView?.[key]) ? sourceObjectsByView[key] : []);

    const nextDrawingAreasByView = {
      ...(current?.drawingAreasByView && typeof current.drawingAreasByView === 'object' ? current.drawingAreasByView : {}),
    } as Record<string, any[]>;
    const sourceAreasByView = (snapshot?.drawingAreasByView && typeof snapshot.drawingAreasByView === 'object') ? snapshot.drawingAreasByView : {};
    if (Object.prototype.hasOwnProperty.call(sourceAreasByView, key)) {
      nextDrawingAreasByView[key] = cloneJson(Array.isArray(sourceAreasByView?.[key]) ? sourceAreasByView[key] : []);
    }

    const nextViewportByView = {
      ...(current?.viewportByView && typeof current.viewportByView === 'object' ? current.viewportByView : {}),
    } as Record<string, any>;
    const sourceViewportByView = (snapshot?.viewportByView && typeof snapshot.viewportByView === 'object') ? snapshot.viewportByView : {};
    if (sourceViewportByView?.[key]) nextViewportByView[key] = cloneJson(sourceViewportByView[key]);

    return {
      ...current,
      activeViewId: key,
      activeRenderEngineKind: 'fabric',
      objectsByView: nextObjectsByView,
      drawingAreasByView: nextDrawingAreasByView,
      viewportByView: nextViewportByView,
      selection: cloneJson(snapshot?.selection ?? { activeObjectIds: [], activeDrawingAreaId: null }),
    };
  };

  const waitForSnapshotCanvasLoad = async (): Promise<void> => {
    const startedAt = Date.now();
    while ((canvas as any)?.__asukaSnapshotLoading === true) {
      if (Date.now() - startedAt > 15_000) {
        throw new Error('Timed out while restoring the Fabric history snapshot.');
      }
      await new Promise<void>((resolve) => {
        try { requestAnimationFrame(() => resolve()); } catch { setTimeout(resolve, 16); }
      });
    }
  };

  const loadSnapshot = async (index: number, viewId?: string | null): Promise<void> => {
    // Fabric revives images asynchronously. Queue a history command issued
    // while the previous loadFromJSON is still active so undo/redo restores
    // cannot race and leave the older canvas state visible.
    if ((canvas as any)?.__asukaSnapshotLoading === true) {
      await waitForSnapshotCanvasLoad();
      await loadSnapshot(index, viewId);
      return;
    }
    const historyViewId = getHistoryViewKey(viewId);
    const activeTrack = getHistoryTrack(historyViewId);
    if (index < 0 || index >= historyStack.length) {

      return;
    }
    const snap = historyStack[index];
    if (!snap) {

      return;
    }


    cancelPendingHistorySnapshot('loadSnapshot');
    await withHistoryMuted(async () => {
      const nextSnapshot = historyViewId === DOCUMENT_HISTORY_VIEW_ID
        ? buildDocumentScopedHistorySnapshot(cloneJson(snap))
        : buildViewScopedHistorySnapshot(cloneJson(snap), historyViewId);
      loadDocumentSnapshot(nextSnapshot);
      await waitForSnapshotCanvasLoad();
      historyIndex = index;
      try { lastHistorySignature = getHistorySnapshotSignature(snap); } catch {}
      persistActiveHistoryVarsToTrack(activeTrack);
      persistFabricHistoryTrackToStore(historyViewId, `loadSnapshot:${index}`);
      try { requestAnimationFrame(() => canvas.requestRenderAll?.()); } catch {}
      try { emitAfter('state.restore', { snapshot: cloneJson(snap), options: { source: 'history', index }, history: true }); } catch {}

      notifyChange();
    });
  };


  type GuideKind = 'zone' | 'object' | 'canvas';
  type Axis = 'x' | 'y';
  type Guide = { kind: GuideKind; axis: Axis; value: number; span: { from: number; to: number }; id: string };

  const rank = (k: GuideKind) => {
    const priorityOrder = snapConfig.priority === 'objects-first'
      ? ['object', 'zone', 'canvas']
      : ['zone', 'canvas', 'object'];
    const i = priorityOrder.indexOf(k);
    return i === -1 ? 999 : i;
  };

  const buildGuides = (activeObject: any): { x: Guide[]; y: Guide[]; cw: number; ch: number } => {
    const guidesX: Guide[] = [];
    const guidesY: Guide[] = [];
    // Guides should be based on the *document* size (active view), not the
    // host canvas pixel size. This keeps snapping/centering consistent with
    // view dimensions like 800x600.
    const cw = Math.max(1, Math.floor(docWidth));
    const ch = Math.max(1, Math.floor(docHeight));

	    // Canvas (DOCUMENT) guides are defined in document/world coordinates.
	    // Snapping runs in the same coordinate space as
	    // `object.getBoundingRect(true,true)` (absolute viewport coords).
	    // So we will transform these guides into absolute coords below.
	    guidesX.push({ kind: 'canvas', axis: 'x', value: 0, id: 'canvas.left', span: { from: 0, to: ch } });
	    guidesX.push({ kind: 'canvas', axis: 'x', value: cw / 2, id: 'canvas.cx', span: { from: 0, to: ch } });
	    guidesX.push({ kind: 'canvas', axis: 'x', value: cw, id: 'canvas.right', span: { from: 0, to: ch } });
	    guidesY.push({ kind: 'canvas', axis: 'y', value: 0, id: 'canvas.top', span: { from: 0, to: cw } });
	    guidesY.push({ kind: 'canvas', axis: 'y', value: ch / 2, id: 'canvas.cy', span: { from: 0, to: cw } });
	    guidesY.push({ kind: 'canvas', axis: 'y', value: ch, id: 'canvas.bottom', span: { from: 0, to: cw } });

    // Zones
    const zones = (drawingZones ?? null) as any[] | null;
    // If there are no drawing zones for the current view, do NOT inject a
    // fallback zone here. A fallback zone would duplicate "canvas" guides and,
    // worse, if expressed in a different coordinate space, it produces smaller
    // "mystery" snap lines.
    const zoneListAll = Array.isArray(zones) && zones.length ? zones : [];
    // IMPORTANT: objects inside a drawing area should NOT snap to other drawing areas.
    // They may still snap to other objects (even from other areas).
    const activeZoneId = (activeObject as any)?._asukaZoneId ?? null;
    const zoneList = activeZoneId
      ? (zoneListAll.filter((z) => (z as any)?.id === activeZoneId) || [])
      : zoneListAll;
    const effectiveZoneList = zoneList.length ? zoneList : zoneListAll;
    effectiveZoneList.forEach((z, idx) => {
      const left = z.left ?? z.x ?? 0;
      const top = z.top ?? z.y ?? 0;
      const width = z.width ?? 0;
      const height = z.height ?? 0;
      const right = left + width;
      const bottom = top + height;
      const cx = left + width / 2;
      const cy = top + height / 2;
      const zid = z.id ?? `zone.${idx}`;

      guidesX.push({ kind: 'zone', axis: 'x', value: left, id: `${zid}.left`, span: { from: top, to: bottom } });
      guidesX.push({ kind: 'zone', axis: 'x', value: cx, id: `${zid}.cx`, span: { from: top, to: bottom } });
      guidesX.push({ kind: 'zone', axis: 'x', value: right, id: `${zid}.right`, span: { from: top, to: bottom } });

      guidesY.push({ kind: 'zone', axis: 'y', value: top, id: `${zid}.top`, span: { from: left, to: right } });
      guidesY.push({ kind: 'zone', axis: 'y', value: cy, id: `${zid}.cy`, span: { from: left, to: right } });
      guidesY.push({ kind: 'zone', axis: 'y', value: bottom, id: `${zid}.bottom`, span: { from: left, to: right } });
    });


	    // IMPORTANT:
	    // Keep all guides (canvas + zones + objects) in *document/world* coordinates.
	    // We compute the moving object's bounds using
	    // `getBoundingRect(true,true)` (absolute coords that IGNORE viewportTransform),
	    // so snapping is stable and independent from zoom/pan.
	    //
	    // Visual snap lines (fabric.Line objects) are placed in world coordinates,
	    // and Fabric applies the viewportTransform when rendering them.

    // Other objects
    try { rebuildRuntimeObjectIndex(); } catch {}
    const objs = Array.from(runtimeObjectIndex.values());
      if (ASUKA_CONTROLS_DEBUG) void 0;
    for (const o of objs) {
      if (!o || o === activeObject || o === drawingZoneRect) continue;
      if ((o as any)._asukaRole && (o as any)._asukaRole !== 'user') continue;
      const obr = o.getBoundingRect ? o.getBoundingRect(true, true) : { left: o.left ?? 0, top: o.top ?? 0, width: o.width ?? 0, height: o.height ?? 0 };
      const left = obr.left;
      const top = obr.top;
      const right = left + obr.width;
      const bottom = top + obr.height;
      const cx = left + obr.width / 2;
      const cy = top + obr.height / 2;
      const oid = (o as any)._asukaObjectId ?? (o as any).id ?? `obj.${Math.random().toString(16).slice(2)}`;

      guidesX.push({ kind: 'object', axis: 'x', value: left, id: `${oid}.left`, span: { from: top, to: bottom } });
      guidesX.push({ kind: 'object', axis: 'x', value: cx, id: `${oid}.cx`, span: { from: top, to: bottom } });
      guidesX.push({ kind: 'object', axis: 'x', value: right, id: `${oid}.right`, span: { from: top, to: bottom } });

      guidesY.push({ kind: 'object', axis: 'y', value: top, id: `${oid}.top`, span: { from: left, to: right } });
      guidesY.push({ kind: 'object', axis: 'y', value: cy, id: `${oid}.cy`, span: { from: left, to: right } });
      guidesY.push({ kind: 'object', axis: 'y', value: bottom, id: `${oid}.bottom`, span: { from: left, to: right } });
    }

    return { x: guidesX, y: guidesY, cw, ch };
  };

  const applySnapping = (object: any) => {
    if (!object || object === drawingZoneRect) return;

    const prev = (object as any).__asukaSnapPrev ?? { x: null as any, y: null as any };
    const { x: rawGuidesX, y: rawGuidesY, cw, ch } = buildGuides(object);

    // snapCenter toggles whether center guides/points are considered.
    const allowCenter = isListenerEnabled('snapCenter');
    const isCenterGuide = (g: any) => {
      const id = String(g?.id ?? '');
      return id.endsWith('.cx') || id.endsWith('.cy') || id === 'canvas.cx' || id === 'canvas.cy';
    };

    const guidesX = allowCenter ? rawGuidesX : rawGuidesX.filter((g) => !isCenterGuide(g));
    const guidesY = allowCenter ? rawGuidesY : rawGuidesY.filter((g) => !isCenterGuide(g));

    const br = object.getBoundingRect ? object.getBoundingRect(true, true) : { left: object.left ?? 0, top: object.top ?? 0, width: object.width ?? 0, height: object.height ?? 0 };

    // Listener settings: smart snap + magnet strength
    const s: any = useAsukaStore.getState?.();
    const magnet = Math.min(1, Math.max(0, Number(s?.listenerSettings?.magnetStrength ?? 1)));
    const smartSnap = s?.listenerSettings?.smartSnap !== false;
    const minF = Math.max(0.1, Number(s?.listenerSettings?.smartSnapMinFactor ?? 0.6));
    const maxF = Math.max(minF, Number(s?.listenerSettings?.smartSnapMaxFactor ?? 1.6));

    const vel = (object as any).__asukaMoveVel;
    const speed = typeof vel?.speed === 'number' ? vel.speed : 0;
    // speed is px/ms; tune factor so slow moves snap stronger.
    let speedFactor = 1;
    if (smartSnap) {
      if (speed <= 0.2) speedFactor = maxF;
      else if (speed >= 1.5) speedFactor = minF;
      else {
        const t = (speed - 0.2) / (1.5 - 0.2);
        speedFactor = maxF + (minF - maxF) * t;
      }
    }

	    // Snap thresholds are defined in *screen px* (what the user feels).
	    // Convert to world units so snapping feels consistent at any zoom.
	    const zoom = Math.max(0.0001, Number(canvas.getZoom?.() ?? 1) || 1);
	    const threshold = (snapConfig.threshold / zoom) * speedFactor;
	    const release = (snapConfig.release / zoom) * speedFactor;

    const weights = (s?.listenerSettings?.snapPriorityWeights ?? {
      zoneEdge: 0.8,
      zoneCenter: 1.05,
      canvasEdge: 0.95,
      canvasCenter: 1.15,
      objectEdge: 1.0,
      objectCenter: 1.2,
    }) as any;

    const weightFor = (g: any, point: string) => {
      const isCenter = point === 'center';
      const kind: GuideKind = (g?.kind ?? 'object') as any;
      if (kind === 'zone') return Number(isCenter ? weights.zoneCenter : weights.zoneEdge) || 1;
      if (kind === 'canvas') return Number(isCenter ? weights.canvasCenter : weights.canvasEdge) || 1;
      return Number(isCenter ? weights.objectCenter : weights.objectEdge) || 1;
    };

    const pullForDist = (dist: number) => {
      if (threshold <= 0) return 1;
      const closeness = Math.max(0, Math.min(1, 1 - dist / threshold));
      // At dist=0 -> 1, at dist=threshold -> magnet
      return Math.min(1, magnet + (1 - magnet) * closeness);
    };
    const w = br.width;
    const h = br.height;

    const pickAxis = (axis: Axis, guides: Guide[]) => {
      const curr = axis === 'x'
        ? { left: br.left, center: br.left + w / 2, right: br.left + w }
        : { top: br.top, center: br.top + h / 2, bottom: br.top + h };

      // Candidate = set br.left/br.top so that one of the points matches guide.
      type Pick = { guide: Guide; point: string; delta: number; score: number; snapped: number; dist: number };
      let best: Pick | null = null;

      for (const g of guides) {
        const baseRank = rank(g.kind);

        if (axis === 'x') {
          const options: Array<{ point: 'left' | 'center' | 'right'; snappedBrLeft: number; dist: number }> = [
            { point: 'left', snappedBrLeft: g.value, dist: Math.abs(curr.left - g.value) },
            ...(allowCenter ? ([{ point: 'center' as const, snappedBrLeft: g.value - w / 2, dist: Math.abs(curr.center - g.value) }] as const) : []),
            { point: 'right' as const, snappedBrLeft: g.value - w, dist: Math.abs(curr.right - g.value) },
          ];
          for (const opt of options) {
            if (opt.dist > threshold) continue;
            const dx = opt.snappedBrLeft - br.left;
            const score = opt.dist * 1000 * weightFor(g, opt.point) + baseRank;
            const candidate: Pick = { guide: g, point: opt.point, delta: dx, score, snapped: g.value, dist: opt.dist };
            if (!best || candidate.score < best.score) best = candidate;
          }
        } else {
          const options: Array<{ point: 'top' | 'center' | 'bottom'; snappedBrTop: number; dist: number }> = [
            { point: 'top', snappedBrTop: g.value, dist: Math.abs(curr.top - g.value) },
            ...(allowCenter ? ([{ point: 'center' as const, snappedBrTop: g.value - h / 2, dist: Math.abs(curr.center - g.value) }] as const) : []),
            { point: 'bottom' as const, snappedBrTop: g.value - h, dist: Math.abs(curr.bottom - g.value) },
          ];
          for (const opt of options) {
            if (opt.dist > threshold) continue;
            const dy = opt.snappedBrTop - br.top;
            const score = opt.dist * 1000 * weightFor(g, opt.point) + baseRank;
            const candidate: Pick = { guide: g, point: opt.point, delta: dy, score, snapped: g.value, dist: opt.dist };
            if (!best || candidate.score < best.score) best = candidate;
          }
        }
      }

      // Tie-break / stickiness: if previous snapped guide is still within release distance, keep it.
      const prevAxis = axis === 'x' ? prev.x : prev.y;
      if (prevAxis && typeof prevAxis.value === 'number') {
        const guideVal = prevAxis.value;
        const point = prevAxis.point;
        const dist = Math.abs((curr as any)[point] - guideVal);
        if (dist <= release) {
          // Keep previous snap (stable): translate by the alignment error of the SAME guide+point.
          // This avoids oscillations near the threshold because we never switch candidate while locked.
          const delta = guideVal - (curr as any)[point];
          return { keep: true, delta, span: prevAxis.span, value: guideVal, point, dist };
        }
      }

      if (!best) return null;
      return { keep: false, delta: best.delta, span: best.guide.span, value: best.guide.value, point: best.point, kind: best.guide.kind, id: best.guide.id, dist: best.dist };
    };

    const pickX = pickAxis('x', guidesX);
    const pickY = pickAxis('y', guidesY);

    const didAdjust = !!pickX || !!pickY;

    if (pickX) {
      let px = pullForDist(Number((pickX as any).dist ?? 0));
      // Magnetic stacking: make object-to-object alignment feel "sticky" without forcing hard snaps.
      try {
        const st: any = useAsukaStore.getState?.();
        const settings = st?.listenerSettings ?? {};
        if (settings.magneticStacking !== false && (pickX as any).kind === 'object') {
          const ms = Math.min(1, Math.max(0, Number(settings.magneticStackingStrength ?? 0.75)));
          px = px + (1 - px) * ms;
        }
      } catch {}
      object.left = (object.left ?? 0) + pickX.delta * px;
      prev.x = { value: pickX.value, point: pickX.point, span: pickX.span };
    } else {
      prev.x = null;
    }
    if (pickY) {
      let py = pullForDist(Number((pickY as any).dist ?? 0));
      try {
        const st: any = useAsukaStore.getState?.();
        const settings = st?.listenerSettings ?? {};
        if (settings.magneticStacking !== false && (pickY as any).kind === 'object') {
          const ms = Math.min(1, Math.max(0, Number(settings.magneticStackingStrength ?? 0.75)));
          py = py + (1 - py) * ms;
        }
      } catch {}
      object.top = (object.top ?? 0) + pickY.delta * py;
      prev.y = { value: pickY.value, point: pickY.point, span: pickY.span };
    } else {
      prev.y = null;
    }

    (object as any).__asukaSnapPrev = prev;

    // Indicators
    snapLinesRuntime.show({
      x: prev.x ? { value: prev.x.value, span: prev.x.span ?? { from: 0, to: ch } } : null,
      y: prev.y ? { value: prev.y.value, span: prev.y.span ?? { from: 0, to: cw } } : null,
    });

    // PERF: setCoords() is relatively expensive; only call it when we actually
    // adjusted the object's position due to snapping.
    if (didAdjust) {
      object.setCoords?.();
    }
  };
  // PERF: Fabric can emit many moving/scaling events per animation frame.
  // We keep live feedback (stroke color + lastGood tracking) but throttle the
  // expensive work to at most once per frame.
  let rafZoneFeedback = 0 as any;
  let pendingZoneTarget: any = null;

  const scheduleZoneFeedback = (target: any) => {
    if (!isListenerEnabled('constraints')) return;
    pendingZoneTarget = target;
    if (rafZoneFeedback) return;
    rafZoneFeedback = requestAnimationFrame(() => {
      rafZoneFeedback = 0;
      const t = pendingZoneTarget;
      pendingZoneTarget = null;
      if (!t) return;
      const status = classifyObjectPosition(t, getZoneForObject(t));
      if (isListenerEnabled('adjustBoundingBoxColors') || isListenerEnabled('boundingBoxesBorders')) {
        updateObjectStrokeForZone(t, status);
      }
      if (isListenerEnabled('preventBoundingBoxExit')) {
        rememberLastGoodPosition(t, status);
      } else {
        updateFabricPlacement(t, status);
      }
    });
  };


  // ---------------------------------------------------------------------------
  // Boundary / physics helpers (elastic edges, spring, collisions)

  const getCanvasBoundaryRect = () => {
    const cw = canvas.getWidth?.() ?? (canvas as any).width ?? 0;
    const ch = canvas.getHeight?.() ?? (canvas as any).height ?? 0;
    return { left: 0, top: 0, width: cw, height: ch };
  };

  const getBoundaryForObject = (target: any) => {
    // If constraints/drawing zones are enabled and the object participates, use its zone if available.
    const participatesInZones = !(target as any)?._asukaIgnoreDrawingZones && isListenerEnabled('constraints');
    if (participatesInZones) {
      try {
        const z = getZoneForObject(target);
        const w = Number(z?.width ?? 0);
        const h = Number(z?.height ?? 0);
        // Fallback to canvas if zone is missing/invalid (e.g. no drawing areas on the view).
        if (w > 0 && h > 0) return getZoneInnerRect(z);
      } catch {}
    }
    return getCanvasBoundaryRect();
  };

  const shouldConstrainObjectBoundary = (target: any) => {
    if (!target) return false;
    if ((target as any)._asukaRole && (target as any)._asukaRole !== 'user') return false;
    if ((target as any)._asukaIgnoreDrawingZones) return false;
    if (!isListenerEnabled('constraints')) return false;

    try {
      const zone = getZoneForObject(target);
      const policy = getPolicy(String(zone?.id ?? ''));
      const allowOverflow = policy?.constraints?.allowOverflow;

      // Explicit policy always wins.
      if (allowOverflow === true) return false;
      if (allowOverflow === false) return true;
    } catch {}

    // Important:
    // preventBoundingBoxExit is NOT a live clamp. It only reverts after a transform
    // if the object is fully outside. Partial overflow must stay possible by default.
    return isListenerEnabled('preventBoundingBoxOvertaking');
  };

  const shouldAutoFitObjectToDrawingArea = (_obj: any, zone: any) => {
    try {
      const policy = getPolicy(String(zone?.id ?? ''));
      // Newly added user layers should fit their container by default.
      // Integrators can still opt out per drawing area with constraints.autoFit = false.
      return policy?.constraints?.autoFit !== false;
    } catch {
      return true;
    }
  };

  const computeOutsideDelta = (br: any, boundary: any) => {
    const bLeft = boundary.left ?? 0;
    const bTop = boundary.top ?? 0;
    const bRight = bLeft + (boundary.width ?? 0);
    const bBottom = bTop + (boundary.height ?? 0);

    let dx = 0;
    let dy = 0;

    if (br.left < bLeft) dx = bLeft - br.left;
    if (br.left + br.width > bRight) dx = bRight - (br.left + br.width);
    if (br.top < bTop) dy = bTop - br.top;
    if (br.top + br.height > bBottom) dy = bBottom - (br.top + br.height);

    return { dx, dy, bLeft, bTop, bRight, bBottom };
  };

  const applySoftBoundaryFeel = (target: any) => {
    if (!target) return;
    if (!shouldConstrainObjectBoundary(target)) return;

    const st: any = useAsukaStore.getState?.();
    const settings = st?.listenerSettings ?? {};
    if (settings.elasticEdges === false) return;

    const boundary = getBoundaryForObject(target);
    const br = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (!br) return;

    const { dx, dy, bLeft, bTop, bRight, bBottom } = computeOutsideDelta(br, boundary);

    // Allow a small overflow, but add resistance and cap the overflow.
    const overflowLimit = Math.max(0, Number(settings.elasticOverflowLimit ?? 14));
    const resistance = Math.min(1, Math.max(0, Number(settings.edgeResistance ?? 0.9)));

    const maxLeftOverflow = bLeft - overflowLimit;
    const maxTopOverflow = bTop - overflowLimit;
    const maxRightOverflow = bRight + overflowLimit;
    const maxBottomOverflow = bBottom + overflowLimit;

    // Cap bounding rect into extended boundary (overflowLimit).
    let capDx = 0;
    let capDy = 0;
    if (br.left < maxLeftOverflow) capDx = maxLeftOverflow - br.left;
    if (br.top < maxTopOverflow) capDy = maxTopOverflow - br.top;
    if (br.left + br.width > maxRightOverflow) capDx = maxRightOverflow - (br.left + br.width);
    if (br.top + br.height > maxBottomOverflow) capDy = maxBottomOverflow - (br.top + br.height);

    // Resistance applies when trying to cross the hard boundary (not the extended cap).
    const rdx = dx ? dx * resistance : 0;
    const rdy = dy ? dy * resistance : 0;

    const totalDx = capDx || rdx;
    const totalDy = capDy || rdy;

    if (totalDx || totalDy) {
      target.left = (target.left ?? 0) + totalDx;
      target.top = (target.top ?? 0) + totalDy;
      try { target.setCoords?.(); } catch {}
    }
  };

  const fitObjectInsideBoundaryPreserveCenter = (target: any, centerOverride?: { x: number; y: number } | null) => {
    if (!target) return false;
    if (!shouldConstrainObjectBoundary(target)) return false;

    const boundary = getBoundaryForObject(target);
    const br0 = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (!boundary || !br0) return false;

    const center = centerOverride
      ?? (target as any).__asukaScalingCenter
      ?? (target as any).__asukaFixedScalingCenter
      ?? (() => {
        try { return target.getCenterPoint?.(); } catch { return null; }
      })();

    const cx = Number(center?.x);
    const cy = Number(center?.y);
    if (!Number.isFinite(cx) || !Number.isFinite(cy)) return false;

    const bLeft = Number(boundary.left ?? 0);
    const bTop = Number(boundary.top ?? 0);
    const bRight = bLeft + Number(boundary.width ?? 0);
    const bBottom = bTop + Number(boundary.height ?? 0);

    const maxWidthAroundCenter = Math.max(1, Math.min(cx - bLeft, bRight - cx) * 2);
    const maxHeightAroundCenter = Math.max(1, Math.min(cy - bTop, bBottom - cy) * 2);

    const fitScaleX = br0.width > maxWidthAroundCenter ? (maxWidthAroundCenter / Math.max(1, br0.width)) : 1;
    const fitScaleY = br0.height > maxHeightAroundCenter ? (maxHeightAroundCenter / Math.max(1, br0.height)) : 1;
    const fit = Math.min(1, fitScaleX, fitScaleY);
    if (!(fit > 0 && fit < 0.999999)) {
      try { target.setPositionByOrigin?.({ x: cx, y: cy }, 'center', 'center'); } catch {}
      try { target.setCoords?.(); } catch {}
      return false;
    }

    let scaleX = Number(target.scaleX);
    let scaleY = Number(target.scaleY);
    if (!Number.isFinite(scaleX) || scaleX === 0) scaleX = 1;
    if (!Number.isFinite(scaleY) || scaleY === 0) scaleY = 1;

    const signX = scaleX < 0 ? -1 : 1;
    const signY = scaleY < 0 ? -1 : 1;

    try {
      target.set?.({
        scaleX: signX * Math.max(0.0001, Math.abs(scaleX) * fit),
        scaleY: signY * Math.max(0.0001, Math.abs(scaleY) * fit),
      });
      target.setPositionByOrigin?.({ x: cx, y: cy }, 'center', 'center');
      target.setCoords?.();
    } catch {}

    const br1 = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (br1 && (br1.width > maxWidthAroundCenter + 0.5 || br1.height > maxHeightAroundCenter + 0.5)) {
      const secondFitX = br1.width > maxWidthAroundCenter ? (maxWidthAroundCenter / Math.max(1, br1.width)) : 1;
      const secondFitY = br1.height > maxHeightAroundCenter ? (maxHeightAroundCenter / Math.max(1, br1.height)) : 1;
      const secondFit = Math.min(1, secondFitX, secondFitY);
      if (secondFit > 0 && secondFit < 0.999999) {
        try {
          const sx = Number(target.scaleX);
          const sy = Number(target.scaleY);
          target.set?.({
            scaleX: (sx < 0 ? -1 : 1) * Math.max(0.0001, Math.abs(sx || 1) * secondFit),
            scaleY: (sy < 0 ? -1 : 1) * Math.max(0.0001, Math.abs(sy || 1) * secondFit),
          });
          target.setPositionByOrigin?.({ x: cx, y: cy }, 'center', 'center');
          target.setCoords?.();
        } catch {}
      }
    }

    return true;
  };

  const springBackToBoundary = (target: any) => {
    if (!target) return;
    if (!shouldConstrainObjectBoundary(target)) return;
    if ((target as any).__asukaSpringing) return;
    try { (target as any).__asukaSnapPrev = { x: null, y: null }; } catch {}
    try { snapLinesRuntime.hide(); } catch {}

    const st: any = useAsukaStore.getState?.();
    const settings = st?.listenerSettings ?? {};
    if (settings.elasticEdges === false) return;

    const boundary = getBoundaryForObject(target);
    const br0 = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (!br0) return;

    const out0 = computeOutsideDelta(br0, boundary);
    if (!out0.dx && !out0.dy) return;

    // Target position is fully inside the hard boundary.
    const targetLeft = (target.left ?? 0) + out0.dx;
    const targetTop = (target.top ?? 0) + out0.dy;

    const k = Math.min(1, Math.max(0.01, Number(settings.springK ?? 0.22)));
    const damping = Math.min(0.999, Math.max(0.1, Number(settings.springDamping ?? 0.78)));

    (target as any).__asukaSpringing = true;

    let vx = 0;
    let vy = 0;

    const step = () => {
      if (!target || !target.canvas) {
        (target as any).__asukaSpringing = false;
        return;
      }

      const x = Number(target.left ?? 0);
      const y = Number(target.top ?? 0);

      const ax = (targetLeft - x) * k;
      const ay = (targetTop - y) * k;

      vx = (vx + ax) * damping;
      vy = (vy + ay) * damping;

      target.left = x + vx;
      target.top = y + vy;

      // Elastic post-release movement must not bring snapping guides back.
      try { (target as any).__asukaSnapPrev = { x: null, y: null }; } catch {}
      try { snapLinesRuntime.hide(); } catch {}

      // In soft mode, keep the "feel" but do not allow runaway.
      try { applySoftBoundaryFeel(target); } catch {}

      try { target.setCoords?.(); } catch {}
      try { canvas.requestRenderAll?.(); } catch {}

      const done = Math.abs(targetLeft - Number(target.left ?? 0)) < 0.25
        && Math.abs(targetTop - Number(target.top ?? 0)) < 0.25
        && Math.abs(vx) + Math.abs(vy) < 0.25;

      if (done) {
        target.left = targetLeft;
        target.top = targetTop;
        try { (target as any).__asukaSnapPrev = { x: null, y: null }; } catch {}
        try { snapLinesRuntime.hide(); } catch {}
        try { target.setCoords?.(); } catch {}
        (target as any).__asukaSpringing = false;
        try { scheduleHistorySnapshot(); } catch {}
        try { canvas.requestRenderAll?.(); } catch {}
        return;
      }
      requestAnimationFrame(step);
    };

    requestAnimationFrame(step);
  };

  const getCollisionLayer = (o: any): string => {
    if (!o) return 'none';
    if (o._asukaCollisionLayer) return String(o._asukaCollisionLayer);
    if (o._asukaElementIsInstance || o._asukaElementName) return 'element';
    if (o._asukaRole && o._asukaRole !== 'user') return 'tool';
    return 'user';
  };

  const resolveCollisions = (target: any) => {
    if (!target) return;
    if ((target as any)._asukaRole && (target as any)._asukaRole !== 'user') return;

    const st: any = useAsukaStore.getState?.();
    const settings = st?.listenerSettings ?? {};
    if (settings.collisionEnabled !== true) return;

    const mode: 'soft' | 'hard' = settings.collisionMode === 'hard' ? 'hard' : 'soft';
    const pad = Math.max(0, Number(settings.collisionPadding ?? 0));

    const layer = getCollisionLayer(target);
    if (layer !== 'user') return;

    const br = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (!br) return;

    const others = (canvas.getObjects?.() ?? []).filter((o: any) => {
      if (!o || o === target) return false;
      if ((o as any)._asukaRole && (o as any)._asukaRole !== 'user') return false;
      if ((o as any)._asukaElementIsInstance || (o as any)._asukaElementName) return false;
      return getCollisionLayer(o) === 'user';
    });

    for (const o of others) {
      const obr = o.getBoundingRect ? o.getBoundingRect(true, true) : null;
      if (!obr) continue;

      // AABB overlap check
      const aL = br.left - pad;
      const aT = br.top - pad;
      const aR = br.left + br.width + pad;
      const aB = br.top + br.height + pad;

      const bL = obr.left - pad;
      const bT = obr.top - pad;
      const bR = obr.left + obr.width + pad;
      const bB = obr.top + obr.height + pad;

      const overlapX = Math.min(aR, bR) - Math.max(aL, bL);
      const overlapY = Math.min(aB, bB) - Math.max(aT, bT);

      if (overlapX > 0 && overlapY > 0) {
        if (mode === 'hard') {
          const prev = (target as any).__asukaLastMovePos;
          if (prev && typeof prev.left === 'number' && typeof prev.top === 'number') {
            target.left = prev.left;
            target.top = prev.top;
            try { target.setCoords?.(); } catch {}
          }
          return;
        }

        // Soft resolve: push out along the smallest overlap axis.
        if (overlapX < overlapY) {
          const dir = (br.left + br.width / 2) < (obr.left + obr.width / 2) ? -1 : 1;
          target.left = (target.left ?? 0) + dir * overlapX;
        } else {
          const dir = (br.top + br.height / 2) < (obr.top + obr.height / 2) ? -1 : 1;
          target.top = (target.top ?? 0) + dir * overlapY;
        }
        try { target.setCoords?.(); } catch {}

        // Refresh br for subsequent collisions
        const br2 = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
        if (br2) {
          br.left = br2.left; br.top = br2.top; br.width = br2.width; br.height = br2.height;
        }
      }
    }
  };
  const shrinkObjectInsideBoundaryFromCenter = (target: any, boundary: { left: number; top: number; width: number; height: number }) => {
    if (!target || !boundary) return false;

    const center = (() => {
      try { return target.getCenterPoint?.(); } catch { return null; }
    })();
    const br = (() => {
      try { return target.getBoundingRect?.(true, true); } catch { return null; }
    })();
    if (!center || !br || br.width <= 0 || br.height <= 0) return false;

    const boundaryRight = Number(boundary.left ?? 0) + Number(boundary.width ?? 0);
    const boundaryBottom = Number(boundary.top ?? 0) + Number(boundary.height ?? 0);
    const maxHalfWidth = Math.min(Number(center.x ?? 0) - Number(boundary.left ?? 0), boundaryRight - Number(center.x ?? 0));
    const maxHalfHeight = Math.min(Number(center.y ?? 0) - Number(boundary.top ?? 0), boundaryBottom - Number(center.y ?? 0));
    const maxWidth = Math.max(0, maxHalfWidth * 2);
    const maxHeight = Math.max(0, maxHalfHeight * 2);
    if (maxWidth <= 0 || maxHeight <= 0) return false;

    const widthFactor = maxWidth / br.width;
    const heightFactor = maxHeight / br.height;
    const factor = Math.min(1, widthFactor, heightFactor);
    if (!Number.isFinite(factor) || factor >= 0.999999) return false;

    let scaleX = Number(target.scaleX);
    let scaleY = Number(target.scaleY);
    if (!Number.isFinite(scaleX) || scaleX === 0) scaleX = 1;
    if (!Number.isFinite(scaleY) || scaleY === 0) scaleY = 1;

    target.set?.({
      scaleX: scaleX * factor,
      scaleY: scaleY * factor,
    });
    try { target.setPositionByOrigin?.(center, 'center', 'center'); } catch {}
    try { target.setCoords?.(); } catch {}
    return true;
  };

  const clampObjectInsideBoundary = (target: any) => {
    if (!target) return;
    if (!shouldConstrainObjectBoundary(target)) return;

    const boundary = getBoundaryForObject(target);

    const br = target.getBoundingRect ? target.getBoundingRect(true, true) : null;
    if (!br) return;

    const { dx, dy } = computeOutsideDelta(br, boundary);

    if (dx || dy) {
      const shouldPreserveCenterWhileScaling = currentTransformKind === 'scaling';

      if (shouldPreserveCenterWhileScaling) {
        const shrunk = shrinkObjectInsideBoundaryFromCenter(target, boundary);
        if (shrunk) {
          try { target.setCoords?.(); } catch {}
          return;
        }
      }

      // Hard clamp: preventBoundingBoxOvertaking must guarantee the object never exceeds the boundary.
      // (Edge-resistance "feel" should be handled before exceeding the boundary; once outside we fully correct.)
      target.left = (target.left ?? 0) + dx;
      target.top = (target.top ?? 0) + dy;
      try { target.setCoords?.(); } catch {}
    }
  };

const handleLiveTransform = (event: any) => {
    const target = event?.target;
    if (!target) return;
    if (snapConfig.enabled && isListenerEnabled('snapObjects')) {
      applySnapping(target);
    } else {
      // Ensure tool lines don't stay visible when snapping is disabled.
      snapLinesRuntime.hide();
    }
    if (shouldConstrainObjectBoundary(target)) {
      clampObjectInsideBoundary(target);
    } else {
      // Default: no live boundary push-back, so partial overflow remains allowed.
      // Full exit is handled by preventBoundingBoxExit on object:modified.
      try { applySoftBoundaryFeel(target); } catch {}
    }

    // Collisions (optional): resolve overlaps between user layers.
    try { resolveCollisions(target); } catch {}

    try {
      const fixedConstraints: any = (target as any)?._asukaFixedSlotConstraints ?? (target as any)?._asukaFixedConstraints;
      enforceFixedConstraintPosition(target, fixedConstraints);
    } catch {}

    if (isListenerEnabled('constraints')) {
      scheduleZoneFeedback(target);
    }

	  // PERF: do not force a full re-render during live transforms.
	  // Fabric already renders during interactive transforms; forcing requestRenderAll()
	  // can double-render and cause lag.
  };

let fabricMovePerfStats: any = null;
const flushFabricMovePerfStats = (reason = 'interval') => {
  if (!fabricMovePerfStats || !isAsuka2DPerfDebugEnabled()) return;
  const stats = fabricMovePerfStats;
  fabricMovePerfStats = null;
  asuka2DPerfDebug('designer.fabric.drag.tick', {
    reason,
    count: stats.count,
    totalMs: roundPerf(stats.totalMs),
    avgMs: roundPerf(stats.totalMs / Math.max(1, stats.count)),
    maxMs: roundPerf(stats.maxMs),
    snapMs: roundPerf(stats.snapMs),
    liveTransformMs: roundPerf(stats.liveTransformMs),
    curvedGuideMs: roundPerf(stats.curvedGuideMs),
    targetType: stats.targetType ?? null,
    objectCount: stats.objectCount ?? null,
  });
};

const handleMoving = (event: any) => {
  const perfEnabled = isAsuka2DPerfDebugEnabled();
  const perfStart = perfEnabled ? perfNow() : 0;
  let snapMs = 0;
  let liveTransformMs = 0;
  let curvedGuideMs = 0;
  const target = event?.target;
  const e = event?.e;

  // Fabric 7 can complete a move without firing `object:modified` when our
  // snapping baseline adjusts the target during `object:moving`. Keep the
  // pointer-up history fallback armed for every interactive move.
  __historyNeedsCommitAfterTransform = true;
  __historyTransformTarget = target ?? __historyTransformTarget;

  // Store previous position for hard collision resolution.
  try { if (target) (target as any).__asukaLastMovePos = { left: Number((target as any).left ?? 0), top: Number((target as any).top ?? 0) }; } catch {}

  // Re-apply RAW position from the pointer (stable baseline), then snap on top of it.
  // PERF: only do this when snapping is enabled (otherwise it duplicates Fabric's work).
  // IMPORTANT: do not apply the "raw pointer baseline" override for ActiveSelection.
  // ActiveSelection has its own internal transform math; forcing left/top from the pointer
  // can create rare jumps where one child appears mispositioned.
  if (snapConfig.enabled && isListenerEnabled('snapObjects') && target && target.type !== 'activeSelection' && e && (target as any).__asukaGrab) {
    const snapStart = perfEnabled ? perfNow() : 0;
    const grab = (target as any).__asukaGrab as { dx: number; dy: number };
    const p = getCanvasPointer(e, true);
    target.set?.({
      left: p.x - grab.dx,
      top: p.y - grab.dy,
    });
    // Only recompute coords when we actually override position.
    // Track velocity for smart-snap + inertia
    try {
      const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
      const last = (target as any).__asukaMoveLast;
      const lastP = last?.p;
      const lastT = last?.t;
      if (lastP && typeof lastT === 'number') {
        const dt = Math.max(1, now - lastT);
        const vx = (p.x - lastP.x) / dt;
        const vy = (p.y - lastP.y) / dt;
        (target as any).__asukaMoveVel = { vx, vy, speed: Math.hypot(vx, vy) };
      }
      (target as any).__asukaMoveLast = { p: { x: p.x, y: p.y }, t: now };
    } catch {}
    target.setCoords?.();
    if (perfEnabled) snapMs += perfNow() - snapStart;
  }

  try {
    const fixedConstraints: any = target?._asukaFixedSlotConstraints ?? target?._asukaFixedConstraints;
    enforceFixedConstraintPosition(target, fixedConstraints);
  } catch {}

  startTransform('moving', target);
  const liveStart = perfEnabled ? perfNow() : 0;
  handleLiveTransform(event);
  if (perfEnabled) liveTransformMs = perfNow() - liveStart;

  const guideStart = perfEnabled ? perfNow() : 0;
  try { maybeUpdateCurvedGuides(event); } catch {}
  if (perfEnabled) curvedGuideMs = perfNow() - guideStart;

  if (perfEnabled) {
    const totalMs = perfNow() - perfStart;
    if (!fabricMovePerfStats) {
      fabricMovePerfStats = { count: 0, totalMs: 0, maxMs: 0, snapMs: 0, liveTransformMs: 0, curvedGuideMs: 0, lastLogAt: perfNow(), targetType: null, objectCount: null };
    }
    fabricMovePerfStats.count += 1;
    fabricMovePerfStats.totalMs += totalMs;
    fabricMovePerfStats.maxMs = Math.max(fabricMovePerfStats.maxMs, totalMs);
    fabricMovePerfStats.snapMs += snapMs;
    fabricMovePerfStats.liveTransformMs += liveTransformMs;
    fabricMovePerfStats.curvedGuideMs += curvedGuideMs;
    fabricMovePerfStats.targetType = String(target?.type ?? 'unknown');
    try { fabricMovePerfStats.objectCount = Number(canvas.getObjects?.().length ?? 0); } catch {}
    const now = perfNow();
    if (totalMs >= 12 || now - Number(fabricMovePerfStats.lastLogAt ?? 0) > 500) {
      fabricMovePerfStats.lastLogAt = now;
      flushFabricMovePerfStats(totalMs >= 12 ? 'slow-move' : 'interval');
    }
  }
};

const handleScaling = (event: any) => {
  const target: any = event?.target;
  startTransform('scaling', target);
  __historyTransformTarget = target ?? __historyTransformTarget;

  try {
    if (target && !(target as any).__asukaScalingCenter) {
      const center = target.getCenterPoint?.();
      if (center) {
        (target as any).__asukaScalingCenter = { x: Number(center.x ?? 0), y: Number(center.y ?? 0) };
      }
    }
  } catch {}

  try {
    const c: any = target?._asukaFixedSlotConstraints ?? target?._asukaFixedConstraints;
    if (target && c) {
      applyFixedConstraintsToObject(target, c);
      const hasSizeConstraints = [c?.minWidth, c?.maxWidth, c?.minHeight, c?.maxHeight].some((v) => typeof v === 'number');
      const lockedCenter = (target as any).__asukaScalingCenter ?? (target as any).__asukaFixedScalingCenter;
      if (hasSizeConstraints && lockedCenter && Number.isFinite(lockedCenter.x) && Number.isFinite(lockedCenter.y)) {
        try { target.setPositionByOrigin?.(lockedCenter, 'center', 'center'); } catch {}
        try { target.setCoords?.(); } catch {}
      }
    }
  } catch {}

  __historyNeedsCommitAfterTransform = true;
  handleLiveTransform(event);

  try {
    const c: any = target?._asukaFixedSlotConstraints ?? target?._asukaFixedConstraints;
    const hasSizeConstraints = [c?.minWidth, c?.maxWidth, c?.minHeight, c?.maxHeight].some((v) => typeof v === 'number');
    const lockedCenter = (target as any)?.__asukaScalingCenter ?? (target as any)?.__asukaFixedScalingCenter;
    if (target && c) {
      applyFixedConstraintsToObject(target, c);
      if (hasSizeConstraints && lockedCenter && Number.isFinite(lockedCenter.x) && Number.isFinite(lockedCenter.y)) {
        try { target.setPositionByOrigin?.(lockedCenter, 'center', 'center'); } catch {}
        try { fitObjectInsideBoundaryPreserveCenter(target, lockedCenter); } catch {}
        try { target.setCoords?.(); } catch {}
      }
    } else if (target && lockedCenter && Number.isFinite(lockedCenter.x) && Number.isFinite(lockedCenter.y)) {
      try { target.setPositionByOrigin?.(lockedCenter, 'center', 'center'); } catch {}
      try { fitObjectInsideBoundaryPreserveCenter(target, lockedCenter); } catch {}
      try { target.setCoords?.(); } catch {}
    }
  } catch {}

  try { maybeUpdateCurvedGuides(event); } catch {}
};
const objectCollectionRuntime = attachObjectCollectionRuntime({
  canvas,
  isUserLayer: isAutoFitTargetLayer,
  drawingZones: () => drawingZones,
  getZoneForObject,
  getZoneInnerBounds,
  autoFitObjectToBounds,
  shouldAutoFitObjectToBounds: shouldAutoFitObjectToDrawingArea,
  emitBefore,
  emitAfter,
  ensureOrderingSeq,
  scheduleNormalizeLayers,
  isHistoryRelevantObject,
  scheduleHistorySnapshot,
  syncDocumentObjectsForView,
  fixedSlotsRuntime,
  useAsukaStore,
});

const handleRotating = (event: any) => {
  const target = event?.target;
  startTransform('rotating', target);
  __historyTransformTarget = target ?? __historyTransformTarget;

  try {
    const fixedConstraints: any = target?._asukaFixedSlotConstraints ?? target?._asukaFixedConstraints;
    if (target && fixedConstraints?.lockRotation) {
      const lockedAngle = Number((target as any).__asukaFixedLockedAngle);
      target.set?.({ angle: Number.isFinite(lockedAngle) ? lockedAngle : 0 });
      target.setCoords?.();
    }
  } catch {}

  if (target && isListenerEnabled('snapRotation')) {
    try {
      const s: any = useAsukaStore.getState?.();
      const step = Number(s?.listenerSettings?.snapRotationStep ?? 45);
      const tol = Number(s?.listenerSettings?.snapTolerance ?? 5);
      const angle = Number(target.angle ?? 0);
      const snapped = Math.round(angle / step) * step;
      if (Math.abs(angle - snapped) <= tol) {
        target.set?.({ angle: snapped });
        target.setCoords?.();
      }
    } catch {}
  }

  __historyNeedsCommitAfterTransform = true;
  handleLiveTransform(event);
  try { maybeUpdateCurvedGuides(event); } catch {}
};

attachTransformRuntime({
  canvas,
  handleMoving,
  handleScaling,
  handleRotating,
});

  const textEditingRuntime = attachTextEditingRuntime({
    canvas,
    isCurvedPathTextObject,
    updateCurvedPathObject,
    scheduleHistorySnapshot,
    autoSizeTextboxToContent,
  });


  const objectModifiedRuntime = attachObjectModifiedRuntime({
    canvas,
    instanceId,
    useAsukaStore,
    ensureObjectId,
    isListenerEnabled,
    isPanning: () => __isPanning,
    getTransformState: () => ({
      isTransforming,
      currentTransformObjectId,
      currentTransformKind,
      isLiveTransform,
    }),
    setTransformState: (patch) => {
      if (typeof patch.isTransforming === 'boolean') isTransforming = patch.isTransforming;
      if ('currentTransformObjectId' in patch) currentTransformObjectId = patch.currentTransformObjectId ?? null;
      if (typeof patch.currentTransformKind === 'string') currentTransformKind = patch.currentTransformKind as any;
      if (typeof patch.isLiveTransform === 'boolean') isLiveTransform = patch.isLiveTransform;
    },
    emitBefore,
    emitAfter,
    emitEvent: emitAsukaEvent,
    emitSelectionChange,
    classifyObjectPosition,
    getZoneForObject,
    updateObjectStrokeForZone,
    revertToLastGoodPositionIfNeeded,
    commitPlacement: commitFabricPlacement,
    snapLinesRuntime,
    snapConfig,
    applySnapping,
    clampObjectInsideBoundary,
    applySoftBoundaryFeel,
    resolveCollisions,
    commitHistoryChange,
    springBackToBoundary,
    scheduleHistorySnapshot,
    syncDocumentObjectsForView,
    applyFixedConstraintsToObject,
    fitObjectInsideBoundaryPreserveCenter,
  });


  try {
    (objectCollectionRuntime as any).fixedSlotsRuntime = fixedSlotsRuntime;
  } catch {}

  const drawingAreasLifecycleRuntime = createDrawingAreasLifecycleRuntime({
    drawingAreaRuntime,
    ensureRuntime,
    ensureZonePlaceholder,
    isUserLayer,
    findBestZoneForObject,
    registerObjectInZoneRuntime,
    applyClipForObject,
  });


  // ---------------------------------------------------------------------------
  // API publique DesignerInstance
  // ---------------------------------------------------------------------------

  const defaultRectOptions = {
    left: logicalWidth / 2 - 60,
    top: logicalHeight / 2 - 40,
    width: 120,
    height: 80,
    fill: '#e5e7eb',
    rx: 8,
    ry: 8,
  };

  // ---------------------------------------------------------------------------
  // Layers helpers (F4)
  // Fabric stack reordering helper
  // (Fabric APIs have evolved; support common method names + a safe fallback.)
  // ---------------------------------------------------------------------------
  const moveObjectToIndex = (target: any, index: number, canvasRef: any = canvas) => {
    if (!target || typeof index !== 'number' || Number.isNaN(index)) return;
    try {
      const c = canvasRef ?? canvas;
      // fabric@5
      if (typeof (c as any).moveTo === 'function') {
        (c as any).moveTo(target, index);
        return;
      }
      // fabric@6+ (some builds)
      if (typeof (c as any).moveObjectTo === 'function') {
        (c as any).moveObjectTo(target, index);
        return;
      }
      // Fallback: manipulate internal stack (best-effort)
      const stack = (c as any)?._objects;
      if (Array.isArray(stack)) {
        const from = stack.indexOf(target);
        if (from >= 0) stack.splice(from, 1);
        const to = Math.max(0, Math.min(index, stack.length));
        stack.splice(to, 0, target);
      }
    } catch {
      // never crash on layer ops
    }
  };

  let __orderingSeq = 1;
  let __orderingScheduled = false;
  let __orderingInProgress = false;
  const __orderingScheduledCanvases = new WeakSet<any>();
  const __orderingInProgressCanvases = new WeakSet<any>();

  function ensureOrderingSeq(obj: any, fallback = 0): number {
    if (!obj) return 0;
    const cur = Number((obj as any)._asukaOrderingSeq);
    if (Number.isFinite(cur) && cur > 0) {
      if (cur >= __orderingSeq) __orderingSeq = cur + 1;
      return cur;
    }
    const next = Math.max(__orderingSeq++, Number.isFinite(fallback) ? fallback + 1 : 1);
    try { (obj as any)._asukaOrderingSeq = next; } catch {}
    return next;
  }

  const isUserLayerRuntimeObject = (obj: any): boolean => {
    if (!obj) return false;
    if ((obj as any)._asukaElementName || (obj as any)._asukaElementIsInstance) return false;
    const role = String((obj as any)._asukaRole ?? '').trim();
    if (role && role !== 'user') return false;
    if ((obj as any).excludeFromExport) return false;
    const itemGroup = (obj as any).itemGroup as string | undefined;
    if (itemGroup) return itemGroup !== 'Tools' && itemGroup !== 'Elements';
    const type = (obj as any).type as string | undefined;
    return !!type && type !== 'activeSelection';
  };

  const isOrderingOverlayRole = (role: string): boolean => {
    return role === 'snapVertical' || role === 'snapHorizontal' || role === 'drawingZoneOutline' || role === 'drawingZonePicker' || role === 'shapePreview';
  };

  const isDrawingAreaRole = (role: string): boolean => {
    return role === 'drawingZone' || role === 'drawingZonePlaceholder';
  };

  const isFixedSlotOrderingObject = (obj: any): boolean => {
    return !!(
      obj
      && (
        (obj as any)._asukaFixedSlotIsIndicator
        || (obj as any)._asukaFixedSlotIsContent
        || (obj as any)._asukaFixedSlotId
      )
    );
  };

  const hasOrderingBringToFront = (obj: any): boolean => {
    return Boolean((obj as any)?._asukaBringToFront || (obj as any)?.bringToFront === true);
  };

  const getOrderingBucket = (obj: any): number => {
    const role = String((obj as any)?._asukaRole ?? '');
    const isElement = !!((obj as any)?._asukaElementIsInstance || (obj as any)?._asukaElementName);
    const isFixedSlot = isFixedSlotOrderingObject(obj);
    const isTool = !!role && role !== 'user';
    const bring = hasOrderingBringToFront(obj);

    if (role === 'clipPath') return -1000;
    if (isOrderingOverlayRole(role)) return 10000;
    if (bring) {
      // Explicit rule: when both an Element and a Marker request front priority,
      // the Marker must win. Normal user layers with bringToFront still stay above
      // structural Element/Marker layers.
      if (isFixedSlot) return 7500;
      if (isElement) return 7000;
      if (!isTool) return 8000;
    }
    if (isDrawingAreaRole(role)) return 6000;
    // Default rule: Markers stay above Elements without using Fabric bringToFront,
    // so the normalized stack remains deterministic and serializable. This also
    // covers objects that replaced a marker, not only the marker indicator icon.
    if (isFixedSlot) return 3500;
    if (!isTool && !isElement) return 4000;
    if (isElement) return 3000;
    if (isTool) return 2000;
    return 4000;
  };

  const getOrderingZoneKey = (obj: any): string => {
    const role = String((obj as any)?._asukaRole ?? '');
    // Keep drawing-area internals grouped by zone, but do NOT partition normal
    // user layers by zone before applying layerIndex. The Layers module and the
    // add-object APIs are expected to define the visible stacking order across
    // all user layers globally on the current canvas.
    if (isDrawingAreaRole(role)) return `zone:${String((obj as any)?._asukaZoneId ?? (obj as any)?.id ?? '')}`;
    if ((obj as any)?._asukaIgnoreDrawingZones) return 'zzzz:no-zone';
    const bucket = getOrderingBucket(obj);
    if (bucket === 4000) return 'zzzz:no-zone';
    if (bucket < 4000 || bucket >= 6000) return 'zzzz:no-zone';
    const zid = String((obj as any)?._asukaZoneId ?? '').trim();
    return zid ? `zone:${zid}` : 'zzzz:no-zone';
  };

  const normalizeLayersByRules = (opts?: { assignMissingOnly?: boolean; canvasRef?: any }) => {
    const canvasRef = (opts as any)?.canvasRef ?? canvas;
    if (!canvasRef) return;
    if (__orderingInProgress || __orderingInProgressCanvases.has(canvasRef)) return;
    __orderingInProgress = true;
    __orderingInProgressCanvases.add(canvasRef);
    try {
      const objects = canvasRef.getObjects?.() ?? [];
      objects.forEach((obj: any, idx: number) => {
        if (!obj) return;
        const li = Number((obj as any).layerIndex);
        const seqFallback = Number.isFinite(li) ? li : idx;
        ensureOrderingSeq(obj, seqFallback);
        if (!opts?.assignMissingOnly || typeof (obj as any).layerIndex !== 'number') {
          try { (obj as any).layerIndex = ensureOrderingSeq(obj, seqFallback); } catch {}
        }
      });

      const sorted = [...objects].sort((a: any, b: any) => {
        const bucketDiff = getOrderingBucket(a) - getOrderingBucket(b);
        if (bucketDiff !== 0) return bucketDiff;

        const zoneA = getOrderingZoneKey(a);
        const zoneB = getOrderingZoneKey(b);
        if (zoneA !== zoneB) return zoneA.localeCompare(zoneB);

        const idxA = Number((a as any).layerIndex);
        const idxB = Number((b as any).layerIndex);
        if (Number.isFinite(idxA) && Number.isFinite(idxB) && idxA !== idxB) return idxA - idxB;

        const seqA = ensureOrderingSeq(a);
        const seqB = ensureOrderingSeq(b);
        if (seqA !== seqB) return seqA - seqB;

        return 0;
      });

      sorted.forEach((obj: any, idx: number) => {
        try {
          (obj as any).layerIndex = idx;
          moveObjectToIndex(obj, idx, canvasRef);
        } catch {}
      });
      try { canvasRef.requestRenderAll?.(); } catch {}
    } finally {
      __orderingInProgressCanvases.delete(canvasRef);
      __orderingInProgress = false;
    }
  };

  function scheduleNormalizeLayers(opts?: { assignMissingOnly?: boolean; canvasRef?: any }) {
    const canvasRef = (opts as any)?.canvasRef ?? canvas;
    if (!canvasRef) return;
    if (__orderingScheduled || __orderingScheduledCanvases.has(canvasRef)) return;
    __orderingScheduled = true;
    __orderingScheduledCanvases.add(canvasRef);
    const run = () => {
      __orderingScheduledCanvases.delete(canvasRef);
      __orderingScheduled = false;
      normalizeLayersByRules(opts);
    };
    try { requestAnimationFrame(run); } catch { setTimeout(run, 0); }
  };

// ---------------------------------------------------------------------------
// Curved text (WordArt-like) - create a curved text group from a Textbox
// ---------------------------------------------------------------------------

type AsukaCurveType =
  | 'arch-up'
  | 'arch-down'
  | 'arch-left'
  | 'arch-right'
  | 'circle'
  | 'wave'
  | 'inflate'
  | 'deflate';

type AsukaCurveOpts = {
  /** Optional explicit radius in pixels. */
  radius?: number;
  /**
   * WordArt-like "bend" intensity.
   * -100..100. Positive bends "up/out", negative bends "down/in" depending on type.
   */
  bend?: number;
  /** When true, radius is automatically derived from text length + bend. */
  autoFit?: boolean;
  /** Curve type */
  type?: AsukaCurveType;
  keepUpright?: boolean;
};

function isCurvedTextObject(obj: any): boolean {
  return !!obj && (obj as any)._asukaCurvedText === true && (obj as any)._asukaCurvedTextData;
}
/**
 * Path-based curved text engine (stable bounds, live updates).
 * Curved text uses `_asukaCurvedText`, `_asukaCurvedTextData` and
 * `_asukaCurveEngine: 'path'` as its canonical serialized metadata.
 */
function isCurvedPathTextObject(obj: any): boolean {
  if (!obj) return false;
  if (isCurvedTextObject(obj) && (obj as any)._asukaCurveEngine === 'path') return true;
  if (obj.type === 'group' && (obj as any)._asukaCurvedText && (obj as any)._asukaCurveEngine === 'path') return true;
  return false;
}

type AsukaCurveMode = 'preset' | 'points';

// Shared persisted data for curved text objects (group or path-engine objects).
type AsukaCurveData = {
  type: AsukaCurveType;
  bend: number;
  radius: number;
  text?: string;
  baseline?: number;
  keepUpright?: boolean;

  // Path engine options
  mode?: AsukaCurveMode;
  pointsCount?: number;
  startOffset?: number;
  pathSide?: 'left' | 'right';
  pathAlign?: 'left' | 'center' | 'right';
  engine?: 'path';

  // Measurement cache (used to stabilize bounds between updates)
  baseW?: number;
  baseH?: number;
  baseWOriginal?: number;
  textboxWidth?: number;
  source?: Record<string, any>;
};

// ---------------------------------------------------------------------------
// Minimal text measurement helpers.
// These are intentionally lightweight; they only need to be "good enough" to
// keep layout stable across environments.
// ---------------------------------------------------------------------------

const __measureCanvas: HTMLCanvasElement | null = typeof document !== 'undefined'
  ? document.createElement('canvas')
  : null;

function fontFromBaseStyle(style: any): string {
  const size = Number(style?.fontSize) || 40;
  const family = style?.fontFamily || 'sans-serif';
  const weight = style?.fontWeight || 'normal';
  const fontStyle = style?.fontStyle || 'normal';
  return `${fontStyle} ${weight} ${size}px ${family}`;
}

function measureTextWidth(baseStyle: any, text: string): number {
  try {
    const ctx = __measureCanvas?.getContext('2d');
    if (!ctx) return Math.max(0, String(text).length) * (Number(baseStyle?.fontSize) || 12) * 0.6;
    ctx.font = fontFromBaseStyle(baseStyle);
    return ctx.measureText(String(text)).width;
  } catch {
    return 0;
  }
}

function getCurvedGlyphGeometry(textObject: any): AsukaCurvedGlyphGeometry[] {
  const lines = Array.isArray(textObject?._textLines)
    ? textObject._textLines
    : [Array.from(String(textObject?.text ?? ''))];
  const charBounds = Array.isArray(textObject?.__charBounds)
    ? textObject.__charBounds
    : [];
  const context = __measureCanvas?.getContext('2d') ?? null;
  const glyphs: AsukaCurvedGlyphGeometry[] = [];

  lines.forEach((line: unknown[], lineIndex: number) => {
    if (!Array.isArray(line)) return;
    line.forEach((grapheme: unknown, charIndex: number) => {
      const glyph = String(grapheme ?? '');
      // Whitespace advances the text along the path but paints no pixels. It
      // must not enlarge the visual selection rectangle.
      if (/^\s+$/u.test(glyph)) return;

      const box = charBounds?.[lineIndex]?.[charIndex];
      const x = Number(box?.renderLeft);
      const y = Number(box?.renderTop);
      if (!Number.isFinite(x) || !Number.isFinite(y)) return;

      const style = (() => {
        try {
          return textObject.getCompleteStyleDeclaration?.(lineIndex, charIndex) ?? textObject;
        } catch {
          return textObject;
        }
      })();
      const fontSize = Math.max(1, Number(style?.fontSize ?? textObject?.fontSize) || 16);
      const renderWidth = Math.max(
        1,
        Number(box?.width ?? box?.kernedWidth) ||
          measureTextWidth(style, glyph) ||
          fontSize * 0.6,
      );
      let left = renderWidth / 2;
      let right = renderWidth / 2;
      let ascent = fontSize * 0.78;
      let descent = fontSize * 0.22;

      if (context) {
        try {
          context.font = fontFromBaseStyle(style);
          const metrics = context.measureText(glyph);
          const inkLeft = Number(metrics.actualBoundingBoxLeft);
          const inkRight = Number(metrics.actualBoundingBoxRight);
          const inkAscent = Number(metrics.actualBoundingBoxAscent);
          const inkDescent = Number(metrics.actualBoundingBoxDescent);

          if (Number.isFinite(inkLeft) && Number.isFinite(inkRight) && inkLeft + inkRight > 0) {
            left = Math.max(0, renderWidth / 2 + inkLeft);
            right = Math.max(0, -renderWidth / 2 + inkRight);
          }
          if (Number.isFinite(inkAscent) && inkAscent > 0) ascent = inkAscent;
          if (Number.isFinite(inkDescent) && inkDescent >= 0) descent = inkDescent;
        } catch {}
      }

      glyphs.push({
        x,
        y: y + (Number(style?.deltaY) || 0),
        angle: Number(box?.angle) || 0,
        width: renderWidth,
        left,
        right,
        ascent,
        descent,
      });
    });
  });

  return glyphs;
}

function computeKerningAdjust(_baseStyle: any, _prevChar: string, _nextChar: string): number {
  // Keeping this at 0 keeps layout deterministic and avoids large glyph drift.
  return 0;
}

function getTextVisualWidthPx(obj: any): number {
  try {
    const w = obj?.getScaledWidth?.() ?? obj?.width ?? 0;
    return Number.isFinite(w) ? Number(w) : 0;
  } catch {
    return 0;
  }
}

function ensureCurvedHandlesHidden(obj: any): void {
  try {
    const handles = (obj as any)?._asukaCurveHandles;
    if (Array.isArray(handles)) {
      handles.forEach((h) => {
        if (h && typeof h.set === 'function') h.set({ visible: false, evented: false, selectable: false });
        else if (h) (h as any).visible = false;
      });
    }
  } catch {}
}

function patchCurvedTextLettersText(group: any, text: string): void {
  try {
    const letters = Array.isArray(group?._objects) ? group._objects : [];
    const chars = String(text).split('');
    letters.forEach((it: any, idx: number) => {
      const ch = chars[idx] ?? '';
      if (it && typeof it.set === 'function') it.set({ text: ch });
      else if (it) it.text = ch;
    });
  } catch {}
}

function getTextBaseLenPx(obj: any, _type: AsukaCurveType): number {
  /**
   * IMPORTANT:
   * The "path length" should be derived from the text's horizontal length (baseW),
   * even for vertical presets (arch-left/right). Using baseH collapses the path
   * and produces stacked letters.
   */
  try {
    const data = (obj as any)._asukaCurvedTextData || {};
    const baseW = Number(data.baseW);

    // Keep a stable "user base width" recorded on first curve creation.
    // We still prefer `baseW` when present so the curve can shrink/grow
    // with content changes.
    const baseWOriginal = Number((data as any).baseWOriginal);

    // Prefer the latest computed span when available.
    if (baseW > 10) return baseW;
    // Fallback to original (first-run) span.
    if (baseWOriginal > 10) return baseWOriginal;

    // Fallback: compute once and store to avoid feedback loops when path changes dimensions
    const w = typeof obj.getScaledWidth === 'function' ? obj.getScaledWidth() : (obj.width ?? 0) * (obj.scaleX ?? 1);
    const h = typeof obj.getScaledHeight === 'function' ? obj.getScaledHeight() : (obj.height ?? 0) * (obj.scaleY ?? 1);

    const computed = Math.max(10, Number(w) || 0);

    (obj as any)._asukaCurvedTextData = {
      ...data,
      baseW: Number(w) || data.baseW,
      baseH: Number(h) || data.baseH,
      // Persist the original base width once.
      baseWOriginal: Number(w) || (data as any).baseWOriginal,
    };

    return computed;
  } catch {
    return 200;
  }
}

/** Clamp a number between min and max. */
function clamp(n: number, min: number, max: number): number {
  const v = Number(n);
  if (Number.isNaN(v)) return min;
  return Math.min(max, Math.max(min, v));
}

/**
 * Estimate total rendered text length (px) using Fabric's character bounds when available.
 * This is used to auto-size curves (e.g. circle) and avoid glyph overlap.
 */
function estimateTextLengthPx(textObj: any): number {
  if (!textObj) return 0;

  // We need a measurement that stays stable even when the text is attached to a path.
  // Fabric's __charBounds/calcTextWidth can become unreliable for IText+path (the exact
  // failure mode you see: circle radius doesn't grow => letters overlap).
  // So we prefer a direct Canvas2D measurement.
  try {
    const text = String(textObj.text ?? '');
    if (!text) return 0;

    const el = (fabric as any)?.util?.createCanvasElement?.() || document.createElement('canvas');
    const ctx = el.getContext('2d');
    if (ctx) {
      const fontStyle = textObj.fontStyle ? String(textObj.fontStyle) : '';
      const fontWeight = textObj.fontWeight ? String(textObj.fontWeight) : '';
      const fontSize = Number(textObj.fontSize ?? 16);
      const fontFamily = textObj.fontFamily ? String(textObj.fontFamily) : 'sans-serif';

      // Close enough to Fabric's font declaration for width measurement.
      ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`.trim();
      const measured = Number(ctx.measureText(text).width) || 0;

      // Fabric uses charSpacing in 1/1000 em.
      const charSpacing = Number(textObj.charSpacing ?? 0);
      const extra = charSpacing !== 0
        ? (Math.max(0, text.length - 1) * (charSpacing / 1000) * fontSize)
        : 0;

      const total = measured + extra;
      if (total > 0) return total;
    }
  } catch {}

  // Best-effort fallback: Fabric per-character metrics.
  const cb = textObj.__charBounds?.[0];
  if (Array.isArray(cb) && cb.length) {
    let sum = 0;
    for (let i = 0; i < cb.length; i++) {
      const w = Number(cb[i]?.kernedWidth ?? cb[i]?.width ?? 0);
      if (!Number.isNaN(w)) sum += w;
    }
    if (sum > 0) return sum;
  }

  // Try Fabric internal measurement
  try {
    if (typeof textObj.calcTextWidth === 'function') {
      const w = Number(textObj.calcTextWidth());
      if (!Number.isNaN(w) && w > 0) return w;
    }
  } catch {}

  // Last resort: approximate using fontSize and character count.
  const text = String(textObj.text ?? '');
  const fontSize = Number(textObj.fontSize ?? 16);
  const approxChar = Math.max(6, fontSize * 0.6);
  return text.length * approxChar;
}
/**
 * SVG path metrics helper (browser-only).
 * Used for:
 * - Auto-fit on all curve types (compare required text length vs path length).
 * - Ultra-tight bounding boxes via sampling.
 *
 * NOTE: This relies on SVGPathElement.getTotalLength/getPointAtLength which is widely supported.
 */
type AsukaPathMetrics = { length: number; minX: number; minY: number; maxX: number; maxY: number };

const _asukaPathMetricsCache: Map<string, AsukaPathMetrics> = new Map();
let _asukaSvgPathEl: SVGPathElement | null = null;

function getAsukaSvgPathEl(): SVGPathElement | null {
  try {
    if (typeof document === 'undefined') return null;
    if (_asukaSvgPathEl) return _asukaSvgPathEl;
    _asukaSvgPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement;
    return _asukaSvgPathEl;
  } catch {
    return null;
  }
}

function measureSvgPathMetrics(d: string, samples = 80): AsukaPathMetrics {
  const cached = _asukaPathMetricsCache.get(d);
  if (cached) return cached;

  const el = getAsukaSvgPathEl();
  if (!el) {
    const fallback: AsukaPathMetrics = { length: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
    _asukaPathMetricsCache.set(d, fallback);
    return fallback;
  }

  let length = 0;
  try {
    el.setAttribute('d', d);
    length = Math.max(0, Number(el.getTotalLength?.() || 0));
  } catch {
    length = 0;
  }

  // Sample points to approximate bbox (works for curves, not only path.getBBox()).
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  if (length > 0) {
    const n = Math.max(8, Math.min(400, samples | 0));
    for (let i = 0; i <= n; i++) {
      const l = (length * i) / n;
      try {
        const p = el.getPointAtLength(l);
        if (!p) continue;
        const x = Number((p as any).x);
        const y = Number((p as any).y);
        if (Number.isFinite(x) && Number.isFinite(y)) {
          minX = Math.min(minX, x);
          minY = Math.min(minY, y);
          maxX = Math.max(maxX, x);
          maxY = Math.max(maxY, y);
        }
      } catch {}
    }
  }

  if (!Number.isFinite(minX)) {
    minX = 0; minY = 0; maxX = 0; maxY = 0;
  }

  const metrics: AsukaPathMetrics = { length, minX, minY, maxX, maxY };

  // Simple cache control
  _asukaPathMetricsCache.set(d, metrics);
  if (_asukaPathMetricsCache.size > 400) {
    const firstKey = _asukaPathMetricsCache.keys().next().value;
    if (firstKey) _asukaPathMetricsCache.delete(firstKey);
  }

  return metrics;
}

function buildPresetPathString(type: AsukaCurveType, bend: number, w: number): string {
  // bend: 0..100
  const t = clamp(Number(bend) || 0, 0, 100) / 100;
  const a = Math.max(1, w * 0.35 * t); // amplitude in px (tuned to feel "WordArt-ish")
  const x0 = -w / 2;
  const x1 = w / 2;

  switch (type) {

    case 'circle':
      // handled in updateCurvedPathObject
      // keep a sensible fallback (same bend and width)
      return buildPresetPathString('arch-up', bend, w);
case 'arch-down':
      return `M ${x0} 0 Q 0 ${a} ${x1} 0`;
    case 'arch-left': {
      // rotate arch-up 90° left (vertical)
      const y0 = -w / 2;
      const y1 = w / 2;
      return `M 0 ${y0} Q ${-a} 0 0 ${y1}`;
    }
    case 'arch-right': {
      const y0 = -w / 2;
      const y1 = w / 2;
      return `M 0 ${y0} Q ${a} 0 0 ${y1}`;
    }
    case 'inflate': {
      // bulge up (cubic)
      return `M ${x0} 0 C ${x0} ${-a} ${x1} ${-a} ${x1} 0`;
    }
    case 'deflate': {
      return `M ${x0} 0 C ${x0} ${a} ${x1} ${a} ${x1} 0`;
    }
    case 'wave': {
      const q = w / 4;
      const a2 = a * 0.55;
      return `M ${x0} 0 Q ${x0 + q} ${-a2} ${x0 + 2 * q} 0 Q ${x0 + 3 * q} ${a2} ${x1} 0`;
    }
    case 'arch-up':
    default:
      return `M ${x0} 0 Q 0 ${-a} ${x1} 0`;
  }
}

function getCurvedPathInnerText(active: any): any | null {
  if (!active) return null;
  if (active.type === 'group') {
    const objs: any[] = (active as any)._objects || [];
    const t = objs.find(
      (o) =>
        o &&
        (o.type === 'i-text' || o.type === 'text' || o.type === 'textbox') &&
        (o as any)._asukaCurveEngine === 'path'
    );
    return t || null;
  }
  return active;
}

function removeCurveHandles(obj: any) {
  try {
    const c = obj?.canvas;
    const handles: any[] = (obj as any)._asukaCurveHandles || [];
    handles.forEach((h) => {
      try { c?.remove?.(h); } catch {}
    });
    (obj as any)._asukaCurveHandles = [];
  } catch {}
}

function ensureCurveHandlesForObject(obj: any) {
  // Supports only 1-point quadratic editing for now (simple + robust).
  try {
    const data = (obj as any)._asukaCurvedTextData || {};
    const mode: AsukaCurveMode = data.mode || 'preset';
    const pointsCount = Number(data.pointsCount || 0);

    const c: any = obj?.canvas;
    if (!c) return;

    // Clean up if not in points mode
    if (mode !== 'points' || pointsCount <= 0) {
      removeCurveHandles(obj);
      return;
    }

    // We implement a single control point that moves the quadratic control Y.
    // It maps to `bend` (0..100) in a stable, intuitive way.
    let handles: any[] = (obj as any)._asukaCurveHandles || [];
    if (!handles.length) {
      const h = new fabric.Circle({
        left: obj.left,
        top: obj.top,
        radius: 8,
        fill: '#ffffff',
        stroke: '#666666',
        strokeWidth: 2,
        originX: 'center',
        originY: 'center',
        hasBorders: false,
        hasControls: false,
        objectCaching: false,
      });
      (h as any)._asukaCurveHandle = true;
      (h as any)._asukaCurveOwnerId = (obj as any).id;
      handles = [h];
      (obj as any)._asukaCurveHandles = handles;
      c.add(h);

      h.on('moving', () => {
        try {
          // Convert handle offset to bend. Up = stronger arch-up, down = arch-down feel.
          const dy = (h.top ?? 0) - (obj.top ?? 0);
          const w = getTextVisualWidthPx(obj);
          const maxA = Math.max(1, w * 0.35);
          const t = clamp(-dy / maxA, -1, 1); // up is negative dy
          const bend = Math.round(clamp(Math.abs(t) * 100, 0, 100));
          const type: AsukaCurveType = t >= 0 ? 'arch-up' : 'arch-down';

          (obj as any)._asukaCurvedTextData = { ...data, bend, type, mode: 'points', pointsCount: 1 };
          updateCurvedPathObject(obj, { bend, type, mode: 'points', pointsCount: 1 });
        } catch {}
      });
    }

    // Position handle based on current bend/type
    const h = handles[0];
    const w = getTextVisualWidthPx(obj);
    const maxA = Math.max(1, w * 0.35);
    const bend = clamp(Number(data.bend) || 0, 0, 100) / 100;
    const sign = data.type === 'arch-down' ? 1 : -1;
    h.set({ left: obj.left, top: (obj.top ?? 0) + sign * (-maxA * bend) });
    h.visible = true;
    h.evented = true;
    h.selectable = true;
    h.setCoords?.();
    c.requestRenderAll?.();
  } catch {}
}

function updateCurvedPathObject(
  curvedObject: any,
  next: Partial<AsukaCurveData>
): void {
  const innerText = getCurvedPathInnerText(curvedObject);
  if (!innerText) return;

  const prev: AsukaCurveData = (innerText._asukaCurvedTextData || {
    type: 'arch-up',
    bend: 30,
    radius: 200,
    pathSide: 'left',
    pathAlign: 'center',
    startOffset: 0,
    engine: 'path',
  }) as AsukaCurveData;

  const data: AsukaCurveData = {
    ...prev,
    ...next,
    engine: 'path',
  } as AsukaCurveData;

  // Auto behaviours (Canva-like)
  // - circleAutoFit: grow radius when text grows (prevents glyph overlap)
  // - circleAutoCenter: keep the start centered at the top, unless user adjusts offset
  // These are stored in the curve data so they persist across serialization.
  const nextHasRadius = typeof (next as any)?.radius === 'number' && !Number.isNaN(Number((next as any).radius));
  const nextHasOffset = typeof (next as any)?.startOffset === 'number' && !Number.isNaN(Number((next as any).startOffset));
  const switchedToCircle = (next as any)?.type === 'circle' && prev?.type !== 'circle';

  // If user explicitly changes radius/offset, disable the corresponding auto behaviour.
  if (nextHasRadius) (data as any).circleAutoFit = false;
  if (nextHasOffset) (data as any).circleAutoCenter = false;

  // When switching to circle, default to auto behaviours unless user explicitly overrides.
  if (switchedToCircle) {
    if ((data as any).circleAutoFit === undefined) (data as any).circleAutoFit = true;
    if ((data as any).circleAutoCenter === undefined) (data as any).circleAutoCenter = true;
    // Reset the offset so circle mode starts from a deterministic position.
    if (!nextHasOffset) data.startOffset = 0;
  }

  innerText._asukaCurvedText = true;
  innerText._asukaCurvedTextData = data;
  innerText._asukaCurveEngine = 'path';

  const center = curvedObject.getCenterPoint();
  const baseAngle = (curvedObject as any).angle || 0;

  // Best-effort measure for "how much path length we need"
  const baseLen = Math.max(1, estimateTextLengthPx(innerText));
  const bend = clamp(data.bend ?? 30, 0, 100);

  // NOTE(perf/quality): avoid recreating Path objects on every slider tick.
  // We reuse the same fabric.Path instance when possible.
  let pathStr = '';
  if (data.type === 'circle') {
    // Circle: prevent overlap by guaranteeing circumference >= required text length.
    // We use a stable width measurement (see estimateTextLengthPx).
    const pad = 24; // breathing room so glyphs don't kiss
    const requiredLen = Math.max(1, baseLen + pad);

    // Fit radius from circumference = 2πr
    const fitR = Math.max(24, requiredLen / (Math.PI * 2));

    // In circle mode, the UI "bend" controls how tight the circle is.
    // - bend = 100 => tightest (radius ~= fitR)
    // - bend = 0   => looser (larger radius)
    // Note: we never go below fitR, otherwise glyphs would overlap.
    const bendT = clamp(bend, 0, 100) / 100;
    const bendFactor = 1 + (1 - bendT) * 1.5; // 1.0 .. 2.5
    const desiredR = Math.max(fitR, fitR * bendFactor);

    let r = Number(data.radius ?? 0);
    const hasValidPrevR = !Number.isNaN(r) && r > 0;

    const circleAutoFit = (data as any).circleAutoFit !== false;

    // Canva-like behaviour: when switching *to* circle, start from the desired radius.
    // Otherwise short texts inherit a big previous radius (ex: 160) => huge circle + tiny text.
    if (switchedToCircle && !nextHasRadius) {
      r = desiredR;
    } else if (!nextHasRadius) {
      // If autoFit enabled, always grow to at least fitR.
      if (circleAutoFit) {
        // Bind radius to bend + text length.
        r = desiredR;
      } else {
        // Not auto-fitting: still keep sane bounds.
        r = hasValidPrevR ? r : desiredR;
      }
    } else {
      // Explicit radius in `next` already copied into data.
      r = Number((next as any).radius);
    }

    // Final clamp (allow large designs; circle can get big for long text)
    r = clamp(r, 24, 20000);
    data.radius = r;

    // Path for a full circle.
    // Fabric text-on-path does not support negative start offsets and closed paths do NOT wrap.
    // The most reliable way to "center at 12 o'clock" is:
    //   - keep `pathAlign: "center"`
    //   - start the circle at 6 o'clock so the *midpoint* of the path lands at 12 o'clock.
    const startAngle = Math.PI / 2; // 6 o'clock (90°). Path midpoint => 12 o'clock.

    // Build a full circle path using cubic Béziers (Fabric is most reliable with M/C commands).
    // We start the circle at 6 o'clock so the path midpoint is at 12 o'clock.
    const k = 0.5522847498307936; // 4/3*tan(pi/8) for 90° arcs
    const seg = Math.PI / 2; // 90° per cubic segment

    // Generate 4 cubic segments around the circle.
    // Each segment from a0 -> a1 has control points:
    // p1 = p0 + k*r*tangent(a0), p2 = p3 - k*r*tangent(a1)
    const parts: string[] = [];
    for (let si = 0; si < 4; si++) {
      const a0 = startAngle + si * seg;
      const a1 = a0 + seg;

      const p0x = r * Math.cos(a0);
      const p0y = r * Math.sin(a0);
      const p3x = r * Math.cos(a1);
      const p3y = r * Math.sin(a1);

      const t0x = -Math.sin(a0);
      const t0y = Math.cos(a0);
      const t1x = -Math.sin(a1);
      const t1y = Math.cos(a1);

      const p1x = p0x + k * r * t0x;
      const p1y = p0y + k * r * t0y;
      const p2x = p3x - k * r * t1x;
      const p2y = p3y - k * r * t1y;

      if (si === 0) {
        parts.push(`M ${p0x} ${p0y}`);
      }
      parts.push(`C ${p1x} ${p1y} ${p2x} ${p2y} ${p3x} ${p3y}`);
    }
    parts.push('Z');
    pathStr = parts.join(' ');

    // Auto-center around the top unless user adjusted offset manually.
    const circleAutoCenter = (data as any).circleAutoCenter !== false;
    if (!nextHasOffset && circleAutoCenter) {
      // Center the text around the top by starting after half of the free space.
      data.startOffset = 0;
      data.pathAlign = 'center';
    }
  } else {
    // Presets (arch / wave / inflate / deflate / etc.)
    // Use the object's base width as the path "span" (Canva-like),
    // but if the text would overlap (requiredLen > pathLength), automatically grow the span.
    const pad = 18;
    const requiredLen = Math.max(1, baseLen + pad);

    const autoFit = (data as any).autoFit !== false; // default true

    // Base span comes from stored baseW (or current scaled width on first run).
    let w = Math.max(24, getTextBaseLenPx(curvedObject as any, data.type));

    // Build path and measure its true length.
    let d = buildPresetPathString(data.type, bend, w);
    let metrics = measureSvgPathMetrics(d, 80);

    if (autoFit && metrics.length > 0 && requiredLen > metrics.length - 6) {
      // Length scales roughly linearly with w for our presets.
      const scale = clamp(requiredLen / metrics.length, 1, 50);
      w = Math.max(w, w * scale);
      d = buildPresetPathString(data.type, bend, w);
      metrics = measureSvgPathMetrics(d, 100);
    }

    // Also allow shrinking when text becomes shorter than the current path.
    // Without this, the selection box can grow but never shrink.
    if (autoFit && metrics.length > 0 && requiredLen < metrics.length - 24) {
      // Length scales roughly linearly with w for our presets.
      const scaleDown = clamp(requiredLen / metrics.length, 0.1, 1);
      const nextW = Math.max(24, w * scaleDown);
      if (nextW < w - 0.5) {
        w = nextW;
        d = buildPresetPathString(data.type, bend, w);
        metrics = measureSvgPathMetrics(d, 100);
      }
    }

    // Persist the *current* span for debugging/serialization, but keep
    // `baseWOriginal` intact so the curve can shrink back when text shrinks.
    (data as any).baseW = w;
    if (typeof (data as any).baseWOriginal !== 'number' || !Number.isFinite((data as any).baseWOriginal) || (data as any).baseWOriginal <= 0) {
      (data as any).baseWOriginal = w;
    }

    pathStr = d;
  }

  const parsePath = (fabric as any)?.Path?.parsePath;
  const prevPath: any = (innerText as any)._asukaCurvedPath || (innerText as any).path;
  let nextPath: any = prevPath;

  // Try to update existing Path (preferred)
  try {
    if (nextPath && nextPath.type === 'path') {
      if (typeof nextPath._setPath === 'function') {
        nextPath._setPath(pathStr, false);
      } else if (typeof parsePath === 'function') {
        nextPath.path = parsePath(pathStr);
        nextPath.setDimensions?.();
      } else {
        // Fallback: recreate only if we can't parse/patch.
        nextPath = null;
      }
      nextPath.dirty = true;
    }
  } catch {
    nextPath = null;
  }

  // Create a new Path only when reuse isn't possible.
  if (!nextPath) {
    nextPath = new fabric.Path(pathStr, {
      fill: 'transparent',
      stroke: 'transparent',
      visible: false,
      objectCaching: false,
      evented: false,
      selectable: false,
    });
  }

  (innerText as any)._asukaCurvedPath = nextPath;

  // Quality during scaling: avoid bitmap cache blur.
  try { (innerText as any).objectCaching = false; } catch {}
  try { (innerText as any).noScaleCache = true; } catch {}

  innerText.set({
    path: nextPath,
    pathAlign: data.pathAlign ?? 'center',
    pathSide: data.pathSide ?? 'left',
    pathStartOffset: data.startOffset ?? 0,
    textAlign: 'center',
    originX: 'center',
    originY: 'center',
    angle: baseAngle,
  });

  // Fabric computes the glyph positions while initializing dimensions. This
  // must happen before applying our visual bounds because Fabric otherwise
  // resets width/height to the thin path bounding box.
  try { (innerText as any)._clearCache?.(); } catch {}
  try { (innerText as any).dirty = true; } catch {}
  try { (innerText as any).setPathInfo?.(); } catch {}
  try {
    (innerText as any).initDimensions?.();
  } catch {}

  // Build the selection rectangle from Fabric's actual per-character path
  // positions, including glyph rotation and ascenders/descenders.
  try {
    const rawGlyphBounds = computeCurvedTextSelectionBounds(
      getCurvedGlyphGeometry(innerText),
      0,
    );

    // Fabric positions path glyphs relative to pathOffset. Recenter that
    // offset on the rendered glyph envelope so asymmetric curves do not
    // create a large blank half inside the selection rectangle.
    const pathOffset = (nextPath as any)?.pathOffset;
    if (
      rawGlyphBounds &&
      pathOffset &&
      Number.isFinite(Number(pathOffset.x)) &&
      Number.isFinite(Number(pathOffset.y))
    ) {
      const nextOffsetX = Number(pathOffset.x) + rawGlyphBounds.centerX;
      const nextOffsetY = Number(pathOffset.y) + rawGlyphBounds.centerY;
      try { pathOffset.setXY?.(nextOffsetX, nextOffsetY); } catch {}
      try {
        pathOffset.x = nextOffsetX;
        pathOffset.y = nextOffsetY;
      } catch {}
      try { (innerText as any)._clearCache?.(); } catch {}
      try { (innerText as any).dirty = true; } catch {}
      try { (innerText as any).setPathInfo?.(); } catch {}
      try { (innerText as any).initDimensions?.(); } catch {}
    }

    const fontSize = Math.max(1, Number((innerText as any).fontSize) || 16);
    const strokeWidth = Math.max(0, Number((innerText as any).strokeWidth) || 0);
    const padding = Math.max(2, fontSize * 0.03, strokeWidth + 1.5);
    const glyphBounds = computeCurvedTextSelectionBounds(
      getCurvedGlyphGeometry(innerText),
      padding,
    );

    if (glyphBounds) {
      innerText.set({ width: glyphBounds.width, height: glyphBounds.height });
      (innerText as any)._asukaTightBBox = {
        w: glyphBounds.width,
        h: glyphBounds.height,
        pad: glyphBounds.padding,
        minX: glyphBounds.minX,
        minY: glyphBounds.minY,
        maxX: glyphBounds.maxX,
        maxY: glyphBounds.maxY,
        centerX: glyphBounds.centerX,
        centerY: glyphBounds.centerY,
      };
    } else {
      const metrics = measureSvgPathMetrics(pathStr, 140);
      const fallbackPadding = Math.max(6, fontSize * 0.75) + strokeWidth;
      const width = Math.max(1, metrics.maxX - metrics.minX + fallbackPadding * 2);
      const height = Math.max(1, metrics.maxY - metrics.minY + fallbackPadding * 2);
      innerText.set({ width, height });
      (innerText as any)._asukaTightBBox = {
        w: width,
        h: height,
        pad: fallbackPadding,
      };
    }
  } catch {}

  innerText.setPositionByOrigin(center, 'center', 'center');
  innerText.setCoords();

  // If the curved object is a Group wrapper, update its bounds as well.
  const maybeGroup: any = curvedObject as any;
  if (maybeGroup && maybeGroup.type === 'group') {
    try {
      // Tight bounds: base the group box on the actual rendered bounding rect of the inner text.
      // This is much closer to Canva/Figma behaviour for inflate/deflate/circle.
      const br = innerText.getBoundingRect?.(true, true);
      if (br && Number.isFinite(br.width) && Number.isFinite(br.height)) {
        try { maybeGroup.width = br.width; } catch {}
        try { maybeGroup.height = br.height; } catch {}
      }

      maybeGroup._calcBounds?.();
      maybeGroup._updateObjectsCoords?.();
      try { (maybeGroup as any)._clearCache?.(); } catch {}
      try { (maybeGroup as any).dirty = true; } catch {}
    } catch {}
    maybeGroup.setCoords?.();
  }

  innerText.canvas?.requestRenderAll();
}

function convertTextboxToCurvedPathText(tb: any, opts: { type?: string; bend?: number; radius?: number; text?: string } = {}): any {
  const canvas = tb.canvas as any;
  const center = tb.getCenterPoint();
  const style = extractTextboxStyle(tb);
  const scaleX = Number.isFinite(Number(tb?.scaleX)) ? Number(tb.scaleX) : 1;
  const scaleY = Number.isFinite(Number(tb?.scaleY)) ? Number(tb.scaleY) : 1;
  const angle = Number.isFinite(Number(tb?.angle)) ? Number(tb.angle) : 0;
  const textboxWidth = Math.max(2, Number(tb?.width) || 0);
  const textBaseWidth = Math.max(10, estimateTextLengthPx(tb));
  const textBaseHeight = Math.max(1, Number(tb?.height) || Number(tb?.fontSize) || 1);

  // Build the curved text as a real Fabric IText on a path (no hit-rect/group),
  // so Fabric's own selection box/bounds stay consistent.
  const nextText = typeof opts.text === 'string' ? opts.text : (tb.text || '');
  const CurvedCtor = (fabric as any).IText || (fabric as any).Textbox || (fabric as any).Text;
  const curved = new CurvedCtor(nextText, {
    ...style,
    originX: 'center',
    originY: 'center',
    left: center.x,
    top: center.y,
    angle,
    scaleX,
    scaleY,
    // Keep the converted object selectable. A previous implementation created
    // the path text with `selectable: false`; it could remain active right after
    // conversion, but once deselected Fabric could no longer pick it again.
    selectable: (tb as any).selectable !== false,
    evented: (tb as any).evented !== false,
    hasControls: (tb as any).hasControls !== false,
    hasBorders: (tb as any).hasBorders !== false,
    lockMovementX: Boolean((tb as any).lockMovementX),
    lockMovementY: Boolean((tb as any).lockMovementY),
    lockScalingX: Boolean((tb as any).lockScalingX),
    lockScalingY: Boolean((tb as any).lockScalingY),
    lockRotation: Boolean((tb as any).lockRotation),
    hoverCursor: (tb as any).hoverCursor || 'move',
    // Quality/perf: do not use bitmap cache while scaling
    objectCaching: false,
    noScaleCache: true,
  } as any) as any;

  // Preserve Asuka-specific metadata when present
  if ((tb as any)._asukaId) (curved as any)._asukaId = (tb as any)._asukaId;
  if ((tb as any)._asukaObjectId) (curved as any)._asukaObjectId = (tb as any)._asukaObjectId;
  if ((tb as any)._asukaZoneId) (curved as any)._asukaZoneId = (tb as any)._asukaZoneId;

  const prev = (tb as any)._asukaCurvedTextData || {};
  const normalizedType = (opts.type ?? prev.type ?? 'arch-up') as any;
  (curved as any)._asukaCurvedTextData = {
    ...(prev || {}),
    type: normalizedType,
    bend: opts.bend ?? prev.bend ?? 30,
    radius: opts.radius ?? prev.radius ?? 160,
    text: nextText,
    baseW: textBaseWidth,
    baseH: textBaseHeight,
    baseWOriginal: Number(prev.baseWOriginal) > 10 ? Number(prev.baseWOriginal) : textBaseWidth,
    textboxWidth,
    source: {
      ...(prev.source || {}),
      ...style,
    },
  };

  // Apply custom Asuka controls/corners (otherwise we fallback to Fabric defaults)
  try {
    (asukaApplyControls as any)(curved);
  } catch {}

  // Create/update the path according to curve data
  // Rebuild path + coords using the shared updater (single source of truth)
  updateCurvedPathObject(curved, {});

  // Swap objects only when the curved object is ready
  canvas.remove(tb);
  canvas.add(curved);
  canvas.setActiveObject(curved);

  // Keep zone feedback consistent across conversions.
  // The selection border color is computed from the object's position relative
  // to the active drawing zone.
  try {
    const zone = getZoneForObject(curved);
    const status = classifyObjectPosition(curved, zone);
    updateObjectStrokeForZone(curved, status);
    rememberLastGoodPosition(curved, status);
  } catch {}

  curved.setCoords();
  canvas.requestRenderAll();

  return curved as any;
}

function getTextStyleProps(tb: any): any {
  return {
    fontFamily: tb.fontFamily,
    fontSize: tb.fontSize,
    fontWeight: tb.fontWeight,
    fontStyle: tb.fontStyle,
    fill: tb.fill,
    stroke: tb.stroke,
    strokeWidth: tb.strokeWidth,
    charSpacing: tb.charSpacing,
    underline: tb.underline,
    linethrough: tb.linethrough,
    overline: tb.overline,
    opacity: tb.opacity,
    textBackgroundColor: tb.textBackgroundColor,
    shadow: tb.shadow,
  };
}

/**
 * Extract visual text style from a Fabric Text/Textbox/IText instance.
 *
 * We rebuild a new object when converting to text-on-path. We want to preserve
 * the look (font, fill, stroke, spacing, etc.) without copying transforms.
 */
function extractTextboxStyle(tb: any): any {
  const base = getTextStyleProps(tb) || {};
  return {
    ...base,
    // Typography/layout
    textAlign: tb?.textAlign,
    lineHeight: tb?.lineHeight,
    direction: tb?.direction,
    charSpacing: tb?.charSpacing,
    // Per-character styling (Fabric Textbox)
    styles: tb?.styles,
    // Path related props (kept for completeness; safe if undefined)
    pathAlign: tb?.pathAlign,
    pathSide: tb?.pathSide,
    pathStartOffset: tb?.pathStartOffset,
    // Misc rendering
    opacity: tb?.opacity,
    shadow: tb?.shadow,
    backgroundColor: tb?.backgroundColor,
    // Fabric flags
    splitByGrapheme: tb?.splitByGrapheme,
  };
}

/**
 * Replace an object in the canvas while trying to keep its stacking index.
 * Avoids canvas.insertAt() which can be version-sensitive.
 */
function replaceInCanvas(oldObj: any, newObj: any) {
  const idx = canvas.getObjects().indexOf(oldObj);
  canvas.remove(oldObj);
  canvas.add(newObj);
  try {
    if (idx >= 0) {
      const c: any = canvas as any;
      if (typeof c.moveObjectTo === 'function') c.moveObjectTo(newObj, idx);
      else if (typeof c.moveTo === 'function') c.moveTo(newObj, idx);
      else {
        const arr: any[] = c._objects;
        if (Array.isArray(arr)) {
          const cur = arr.indexOf(newObj);
          if (cur >= 0) {
            arr.splice(cur, 1);
            arr.splice(idx, 0, newObj);
          }
        }
      }
    }
  } catch {}
}

/**
 * Compute radius if autoFit or radius missing, based on bend + text width.
 */
function computeRadius(totalWidth: number, baseStyle: any, opts: AsukaCurveOpts): number {
  const bend = clamp(Number.isFinite(opts?.bend as any) ? Number(opts.bend) : 60, -100, 100);
  const absB = Math.abs(bend) / 100;

  // Default span: between 20° and ~300°
  const span = clamp(Math.PI / 9 + absB * Math.PI * 1.6, Math.PI / 12, Math.PI * 1.8);
  const safeW = Math.max(1, totalWidth);

  // If circle, use up to full 2π based on bend
  if (opts.type === 'circle') {
    const circleSpan = clamp(Math.PI / 2 + absB * Math.PI * 1.5, Math.PI / 2, Math.PI * 2);
    return clamp(safeW / circleSpan, 20, 2000);
  }

  // Wave / inflate / deflate prefer a looser radius (less tight)
  if (opts.type === 'wave' || opts.type === 'inflate' || opts.type === 'deflate') {
    return clamp(safeW / (Math.PI / 6 + absB * Math.PI), 30, 3000);
  }

  return clamp(safeW / span, 20, 3000);
}

function ensureCurvedLetters(group: any, text: string, baseStyle: Record<string, any>): any[] {
  const desired = Array.from(String(text ?? ''));

  const existing: any[] = Array.isArray((group as any)._objects) ? (group as any)._objects : [];
  const next: any[] = [];

  // Reuse existing fabric.Text objects when possible to avoid allocations.
  for (let i = 0; i < desired.length; i++) {
    const ch = desired[i];
    let t = existing[i];

    const isTextLike =
      !!t &&
      (typeof (t as any).isType === 'function'
        ? (t as any).isType('text') || (t as any).isType('i-text') || (t as any).isType('textbox')
        : t.type === 'text' || t.type === 'i-text' || t.type === 'textbox');

    if (!isTextLike) {
      t = new fabric.Text(ch, {
        ...baseStyle,
        selectable: false,
        evented: false,
        originX: 'center',
        originY: 'center',
      });
    } else {
      t.set({
        text: ch,
        ...baseStyle,
        selectable: false,
        evented: false,
        originX: 'center',
        originY: 'center',
      });
    }

    // IMPORTANT: keep Fabric's group relationships consistent
    (t as any).group = group;

    next.push(t);
  }

  // Replace group's objects list in one go, but keep refs consistent
  (group as any)._objects = next;

  try {
    // Ensure children's coords are recalculated in group space
    for (const t of next) {
      (t as any).canvas = group.canvas;
      t.setCoords?.();
    }
    group._calcBounds?.();
    group._updateObjectsCoords?.();
    group.setCoords?.();
  } catch {}

  return next;
}

function getLetterDims(t: any): { w: number; h: number } {
  // Prefer Fabric's internal measurements, but fall back safely.
  let w = 0;
  let h = 0;
  try {
    w = typeof t.getScaledWidth === 'function' ? t.getScaledWidth() : (t.width ?? 0) * (t.scaleX ?? 1);
    h = typeof t.getScaledHeight === 'function' ? t.getScaledHeight() : (t.height ?? 0) * (t.scaleY ?? 1);
  } catch {}
  if (!Number.isFinite(w) || w <= 0) w = Math.max(8, Number(t.fontSize ?? 40) * 0.6);
  if (!Number.isFinite(h) || h <= 0) h = Math.max(12, Number(t.fontSize ?? 40));
  return { w, h };
}

function charSpacingPx(style: Record<string, any>): number {
  // Fabric charSpacing is in 1/1000 em
  const cs = Number((style as any).charSpacing ?? 0);
  const fs = Number((style as any).fontSize ?? 40);
  if (!Number.isFinite(cs) || !Number.isFinite(fs)) return 0;
  return (cs / 1000) * fs;
}

function layoutCurvedLettersInPlace(
  group: any,
  letters: any[],
  opts: { type: AsukaCurveType; bend: number; radius: number; baseline: number; keepUpright?: boolean; baseStyle?: any }
) {
  const type = opts.type;
  const bend = clamp(opts.bend, -100, 100);
  const radius = clamp(opts.radius, 20, 5000);

  // Baseline correction (typographic feel). Positive baseline pushes outward.
  const baseline = Number.isFinite(opts.baseline as any) ? Number(opts.baseline) : 0;

  // Compute total width and per-letter centers
  const dims = letters.map(getLetterDims);

// Typographic spacing: use canvas-measured advances + tracking + kerning.
const tracking = charSpacingPx(opts.baseStyle || {});
const chars = letters.map((t: any) => String(t.text ?? ''));
const advances = chars.map((ch, i) => {
  const w = measureTextWidth(opts.baseStyle || {}, ch) || dims[i].w;
  const k = i > 0 ? computeKerningAdjust(opts.baseStyle || {}, chars[i - 1], ch) : 0;
  return Math.max(0, w + tracking + k);
});

const total = advances.reduce((acc, a) => acc + a, 0);
let cursor = -total / 2;

  const maxAngleDeg =
    type === 'circle' ? 360 :
    type === 'arch-left' || type === 'arch-right' ? 180 :
    type === 'wave' ? 0 : 180;

  const bendAngle = (Math.abs(bend) / 100) * maxAngleDeg;
  const sign = bend >= 0 ? 1 : -1;

  // Wave parameters derived from bend & radius for consistency
  const waveAmp = (Math.abs(bend) / 100) * Math.max(8, radius * 0.12);
  const waveLen = Math.max(40, total / 1.5);

  for (let i = 0; i < letters.length; i++) {
    const t = letters[i];
    const d = dims[i];
    const adv = advances[i] ?? d.w;
    const mid = cursor + adv / 2;
    cursor += adv;

    let x = 0;
    let y = 0;
    let rotDeg = 0;

    if (type === 'wave') {
      // Horizontal layout with sinusoidal offset; tangent rotation for natural feel.
      x = mid;
      const phase = (x / waveLen) * Math.PI * 2;
      y = Math.sin(phase) * waveAmp * sign;

      const dy = Math.cos(phase) * waveAmp * sign * (Math.PI * 2 / waveLen);
      rotDeg = (Math.atan2(dy, 1) * 180) / Math.PI;
    } else if (type === 'inflate' || type === 'deflate') {
      // Bulge (parabola): center highest, edges low. Tangent derived from derivative.
      x = mid;
      const u = total > 0 ? (mid + total / 2) / total : 0.5;
      const parab = 1 - 4 * Math.pow(u - 0.5, 2); // 0 at edges, 1 at center
      const dir = (type === 'inflate' ? 1 : -1) * sign;
      y = -parab * waveAmp * dir;

      const dparab = -8 * (u - 0.5);
      const dy = -(dparab / Math.max(1e-6, total)) * waveAmp * dir;
      rotDeg = (Math.atan2(dy, 1) * 180) / Math.PI;
    } else {
      // Arc/circle: map x -> angle, place on circle. Rotation aligns with tangent.
      const u = total > 0 ? (mid + total / 2) / total : 0.5;
      const start = -bendAngle / 2;
      const aDeg = start + u * bendAngle;

      const a = (aDeg * Math.PI) / 180;
      const r = radius + baseline; // baseline correction

      if (type === 'arch-left' || type === 'arch-right') {
        // Vertical arc (left/right). Treat x as y progression.
        // We'll place letters along a quarter-turned circle around origin.
        // arch-left: curve to the left; arch-right: to the right
        const lr = type === 'arch-left' ? -1 : 1;
        x = Math.sin(a) * r * lr;
        y = -Math.cos(a) * r;
        // Tangent angle: derivative direction. Use a + 90deg, with lr affecting sign.
        rotDeg = 0;
      } else if (type === 'arch-down') {
        x = Math.sin(a) * r;
        y = Math.cos(a) * r;
        // Tangent: a - 90deg (because y is flipped)
        rotDeg = aDeg;
      } else {
        // arch-up and circle
        x = Math.sin(a) * r;
        y = -Math.cos(a) * r;
        // Tangent: a + 90deg
        rotDeg = aDeg;
      }

      // Keep letters upright-ish when bend is small: blend towards 0 rotation.
      const uprightBlend = clamp(1 - Math.abs(bend) / 100, 0, 1);
      rotDeg = rotDeg * (1 - uprightBlend * 0.35);
    }

    // Apply baseline tweak in wave/bulge too (vertical offset)
    if (type === 'wave' || type === 'inflate' || type === 'deflate') {
      y += baseline;
    }

    if (opts.keepUpright) rotDeg = 0;

    t.set({ left: x, top: y, angle: rotDeg });
    try { t.setCoords?.(); } catch {}
  }

  // Update group bounds
  try {
    group._calcBounds?.();
    group._updateObjectsCoords?.();
    group.setCoords?.();
  } catch {}
}

function updateCurvedGroup(group: any, opts: AsukaCurveOpts & { text?: string; baseline?: number }): void {
  if (!isCurvedTextObject(group)) return;
  const data = (group as any)._asukaCurvedTextData || {};

  const nextText = typeof (opts as any)?.text === 'string' ? String((opts as any).text) : String(data.text ?? '');
  const nextType = ((opts?.type ?? data.type ?? 'arch-up') as AsukaCurveType) || 'arch-up';
  const nextBend = clamp(Number.isFinite((opts as any)?.bend) ? Number((opts as any).bend) : Number(data.bend ?? 60), -100, 100);
  const nextAutoFit = typeof (opts as any)?.autoFit === 'boolean' ? Boolean((opts as any).autoFit) : Boolean(data.autoFit);
  const nextBaseline = Number.isFinite((opts as any)?.baseline) ? Number((opts as any).baseline) : Number(data.baseline ?? 0);

  const baseStyle = { ...(data.source || {}) };

  // Ensure letters exist and are styled.
  const letters = ensureCurvedLetters(group, nextText, baseStyle);

  // Compute / choose radius
  const totalWidth = letters.map(getLetterDims).reduce((acc, d) => acc + d.w, 0);
  const nextRadius =
    nextAutoFit || !Number.isFinite(((opts as any)?.radius ?? data.radius) as any)
      ? computeRadius(totalWidth, baseStyle, { type: nextType, bend: nextBend, autoFit: true })
      : clamp(Number((opts as any)?.radius ?? data.radius), 20, 5000);

  // Layout without rebuilding/replacing the group (keeps selection + module context).
  layoutCurvedLettersInPlace(group, letters, {
    type: nextType,
    bend: nextBend,
    radius: nextRadius,
    baseline: nextBaseline,
    keepUpright: Boolean((opts as any)?.keepUpright ?? data.keepUpright),
    baseStyle,
  });

  // Persist metadata
  (group as any)._asukaCurvedText = true;
  (group as any)._asukaCurvedTextData = {
    ...data,
    text: nextText,
    radius: nextRadius,
    bend: nextBend,
    autoFit: nextAutoFit,
    type: nextType,
    baseline: nextBaseline,
    keepUpright: Boolean((opts as any)?.keepUpright ?? data.keepUpright),
    source: {
      ...(data.source || {}),
      ...(baseStyle || {}),
    },
  };

  try {
    group.dirty = true;
  } catch {}
  try {
    const c: any = (group as any).canvas;
    c?.requestRenderAll?.();
  } catch {}
}


const saveBlobAs = (blob: Blob, filename: string) => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
};

const dataUrlToBlob = (dataUrl: string): Blob => {
  const [meta, body=''] = String(dataUrl || '').split(',');
  const mime = /data:([^;]+)/.exec(meta || '')?.[1] || 'application/octet-stream';
  const bin = atob(body);
  const arr = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
  return new Blob([arr], { type: mime });
};

const parseUnitToPx = (value: number | null | undefined, unit: 'px' | 'in' | 'cm' | 'mm' = 'px') => {
  const n = Number(value);
  if (!Number.isFinite(n) || n <= 0) return null;
  try {
    return (fabric as any).util?.parseUnit?.(`${n}${unit}`) ?? n;
  } catch {
    return n;
  }
};

const computeDownloadMultiplier = (baseWidthPx: number, opts: any = {}) => {
  const explicitScale = Number(opts?.scale);
  if (Number.isFinite(explicitScale) && explicitScale > 0) return explicitScale;
  const targetWidthPx = parseUnitToPx(opts?.width ?? null, opts?.unit ?? 'px');
  const widthRatio = targetWidthPx && baseWidthPx > 0 ? targetWidthPx / baseWidthPx : 1;
  const dpi = Math.max(1, Number(opts?.dpi ?? 96) || 96);
  const dpiRatio = dpi / 96;
  return Math.max(0.01, widthRatio * dpiRatio);
};

const zoneShapePath = (ctx: CanvasRenderingContext2D, zone: any, w: number, h: number) => {
  const shape = String(zone?.shape ?? 'rect');
  ctx.beginPath();
  if (shape === 'ellipse') {
    ctx.ellipse(w / 2, h / 2, Math.max(1, w / 2), Math.max(1, h / 2), 0, 0, Math.PI * 2);
    return;
  }
  if (shape === 'triangle') {
    ctx.moveTo(w / 2, 0);
    ctx.lineTo(w, h);
    ctx.lineTo(0, h);
    ctx.closePath();
    return;
  }
  if (shape === 'star') {
    const outerR = Math.max(1, Math.min(w, h) / 2);
    const innerR = Math.max(1, outerR * 0.45);
    const pts = createStarPoints(w / 2, h / 2, 5, outerR, innerR);
    if (pts.length) {
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.closePath();
      return;
    }
  }
  ctx.rect(0, 0, w, h);
};

const maskZoneDataUrlIfNeeded = async (srcDataUrl: string, zone: any, pixelWidth: number, pixelHeight: number): Promise<string> => {
  const shape = String(zone?.shape ?? 'rect');
  if (shape === 'rect') return srcDataUrl;
  const w = Math.max(1, Math.round(pixelWidth));
  const h = Math.max(1, Math.round(pixelHeight));
  const out = document.createElement('canvas');
  out.width = w;
  out.height = h;
  const ctx = out.getContext('2d');
  if (!ctx) return srcDataUrl;
  const baseImg = await new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = srcDataUrl;
  }).catch(() => null as any);
  if (!baseImg) return srcDataUrl;
  ctx.drawImage(baseImg, 0, 0, w, h);
  ctx.globalCompositeOperation = 'destination-in';
  if (shape === 'svg' && typeof zone?.sourceSvg === 'string' && zone.sourceSvg) {
    const svgImg = await new Promise<HTMLImageElement>((resolve, reject) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.onerror = reject;
      img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(zone.sourceSvg))}`;
    }).catch(() => null as any);
    if (svgImg) {
      ctx.drawImage(svgImg, 0, 0, w, h);
      ctx.globalCompositeOperation = 'source-over';
      return out.toDataURL('image/png');
    }
  }
  zoneShapePath(ctx, zone, w, h);
  ctx.fillStyle = '#000';
  ctx.fill();
  ctx.globalCompositeOperation = 'source-over';
  return out.toDataURL('image/png');
};

const exportCanvasToDataUrl = async (opts: any = {}): Promise<string> => {
  const multiplier = computeDownloadMultiplier(Math.max(1, canvas.getWidth?.() ?? docWidth ?? logicalWidth ?? 1), opts);
  return canvas.toDataURL({
    format: opts?.format === 'jpg' ? 'jpeg' : (opts?.format || 'png'),
    multiplier,
    filter: (obj: any) => !isExportHelperObject(obj),
  });
};

const exportObjectToDataUrl = async (object: any, opts: any = {}): Promise<string> => {
  if (!object) return await exportCanvasToDataUrl(opts);
  const rect = object?.getBoundingRect?.(true, true) ?? {
    left: Number(object?.left ?? 0),
    top: Number(object?.top ?? 0),
    width: Number(object?.getScaledWidth?.() ?? object?.width ?? 1),
    height: Number(object?.getScaledHeight?.() ?? object?.height ?? 1),
  };
  const baseWidth = Math.max(1, Number(rect?.width ?? object?.getScaledWidth?.() ?? object?.width ?? 1));
  const multiplier = computeDownloadMultiplier(baseWidth, opts);
  const targetId = String((object as any)?._asukaObjectId ?? '');
  if (canvas?.toDataURL) {
    return canvas.toDataURL({
      format: opts?.format === 'jpg' ? 'jpeg' : (opts?.format || 'png'),
      multiplier,
      left: Number(rect?.left ?? 0),
      top: Number(rect?.top ?? 0),
      width: Math.max(1, Number(rect?.width ?? 1)),
      height: Math.max(1, Number(rect?.height ?? 1)),
      filter: (obj: any) => {
        if (isExportHelperObject(obj)) return false;
        if (!targetId) return obj === object;
        return String((obj as any)?._asukaObjectId ?? '') === targetId;
      },
    });
  }
  if (typeof object?.toDataURL === 'function') {
    return object.toDataURL({
      format: opts?.format === 'jpg' ? 'jpeg' : (opts?.format || 'png'),
      multiplier,
    });
  }
  return await exportCanvasToDataUrl(opts);
};

const exportZoneToDataUrl = async (zone: any, opts: any = {}): Promise<string> => {
  const baseWidth = Math.max(1, Number(zone?.width ?? 1));
  const multiplier = computeDownloadMultiplier(baseWidth, opts);
  const fmt = opts?.format === 'jpg' ? 'jpeg' : (opts?.format || 'png');
  const dataUrl = canvas.toDataURL({
    format: fmt,
    multiplier,
    left: Number(zone?.x ?? zone?.left ?? 0),
    top: Number(zone?.y ?? zone?.top ?? 0),
    width: Math.max(1, Number(zone?.width ?? 1)),
    height: Math.max(1, Number(zone?.height ?? 1)),
    filter: (obj: any) => !isExportHelperObject(obj),
  });
  return await maskZoneDataUrlIfNeeded(dataUrl, zone, Math.max(1, Number(zone?.width ?? 1)) * multiplier, Math.max(1, Number(zone?.height ?? 1)) * multiplier);
};

const serializeTargetToSvg = async (target: any, kind: 'canvas' | 'zone' | 'object'): Promise<string> => {
  if (kind === 'object') return String(target?.toSVG?.() ?? '');
  const url = kind === 'zone' ? await exportZoneToDataUrl(target, { format: 'png', dpi: 96 }) : await exportCanvasToDataUrl({ format: 'png', dpi: 96 });
  const width = Math.max(1, Number(kind === 'zone' ? (target?.width ?? 1) : (canvas.getWidth?.() ?? 1)));
  const height = Math.max(1, Number(kind === 'zone' ? (target?.height ?? 1) : (canvas.getHeight?.() ?? 1)));
  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><image href="${url}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/></svg>`;
};

const drawPdfCropMarks = (pdf: any, x: number, y: number, w: number, h: number) => {
  const cropLength = 10;
  const pts = [
    [x - cropLength, y, x, y], [x, y - cropLength, x, y],
    [x + w, y - cropLength, x + w, y], [x + w, y, x + w + cropLength, y],
    [x - cropLength, y + h, x, y + h], [x, y + h, x, y + h + cropLength],
    [x + w, y + h, x + w + cropLength, y + h], [x + w, y + h, x + w, y + h + cropLength],
  ];
  pdf.setLineWidth(0.25);
  pts.forEach(([x1, y1, x2, y2]) => pdf.line(x1, y1, x2, y2));
};

const __zipCrcTable = (() => {
  const table = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
    table[i] = c >>> 0;
  }
  return table;
})();

const crc32 = (bytes: Uint8Array) => {
  let crc = 0xffffffff;
  for (let i = 0; i < bytes.length; i++) crc = __zipCrcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
  return (crc ^ 0xffffffff) >>> 0;
};

const blobToUint8 = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer());

const sanitizeFilenamePart = (v: string) => String(v || 'file').replace(/[/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim() || 'file';

const makeZipBlob = async (files: Array<{ name: string; blob: Blob }>): Promise<Blob> => {
  const encoder = new TextEncoder();
  const localParts: Uint8Array[] = [];
  const centralParts: Uint8Array[] = [];
  let offset = 0;
  for (const file of files) {
    const name = sanitizeFilenamePart(file.name);
    const nameBytes = encoder.encode(name);
    const data = await blobToUint8(file.blob);
    const crc = crc32(data);
    const local = new Uint8Array(30 + nameBytes.length + data.length);
    const lv = new DataView(local.buffer);
    lv.setUint32(0, 0x04034b50, true);
    lv.setUint16(4, 20, true);
    lv.setUint16(6, 0, true);
    lv.setUint16(8, 0, true);
    lv.setUint16(10, 0, true);
    lv.setUint16(12, 0, true);
    lv.setUint32(14, crc, true);
    lv.setUint32(18, data.length, true);
    lv.setUint32(22, data.length, true);
    lv.setUint16(26, nameBytes.length, true);
    lv.setUint16(28, 0, true);
    local.set(nameBytes, 30);
    local.set(data, 30 + nameBytes.length);
    localParts.push(local);

    const central = new Uint8Array(46 + nameBytes.length);
    const cv = new DataView(central.buffer);
    cv.setUint32(0, 0x02014b50, true);
    cv.setUint16(4, 20, true);
    cv.setUint16(6, 20, true);
    cv.setUint16(8, 0, true);
    cv.setUint16(10, 0, true);
    cv.setUint16(12, 0, true);
    cv.setUint16(14, 0, true);
    cv.setUint32(16, crc, true);
    cv.setUint32(20, data.length, true);
    cv.setUint32(24, data.length, true);
    cv.setUint16(28, nameBytes.length, true);
    cv.setUint16(30, 0, true);
    cv.setUint16(32, 0, true);
    cv.setUint16(34, 0, true);
    cv.setUint16(36, 0, true);
    cv.setUint32(38, 0, true);
    cv.setUint32(42, offset, true);
    central.set(nameBytes, 46);
    centralParts.push(central);
    offset += local.length;
  }
  const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
  const end = new Uint8Array(22);
  const ev = new DataView(end.buffer);
  ev.setUint32(0, 0x06054b50, true);
  ev.setUint16(4, 0, true);
  ev.setUint16(6, 0, true);
  ev.setUint16(8, files.length, true);
  ev.setUint16(10, files.length, true);
  ev.setUint32(12, centralSize, true);
  ev.setUint32(16, offset, true);
  ev.setUint16(20, 0, true);
  return new Blob([...localParts, ...centralParts, end], { type: 'application/zip' });
};

const getViewMetaById = (viewId: string) => {
  try {
    const views = (useAsukaStore.getState() as any).views ?? [];
    return (views as any[]).find((v: any) => String(v?.id ?? '') === String(viewId ?? '')) ?? null;
  } catch {
    return null;
  }
};

const getLiveActiveViewId = () => {
  try {
    return String(((useAsukaStore.getState() as any)?.activeViewId ?? '') || '');
  } catch {
    return '';
  }
};

const afterTwoFrames = async () => {
  await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
};

const settleFabricCanvas = async (tmp: any) => {
  try { tmp.requestRenderAll?.(); } catch {}
  try { tmp.renderAll?.(); } catch {}
  await afterTwoFrames();
  try { tmp.requestRenderAll?.(); } catch {}
  try { tmp.renderAll?.(); } catch {}
};

const createStaticCanvasFromSnapshot = async (viewId: string, options: { forceSnapshot?: boolean } = {}) => {
  const normalizedViewId = String(viewId ?? '').trim();
  const liveActiveViewId = getLiveActiveViewId();
  const useLiveCanvas = !options?.forceSnapshot && normalizedViewId && normalizedViewId === liveActiveViewId;
  if (useLiveCanvas) {
    return { tmp: canvas, width: Math.max(1, Number(canvas.getWidth?.() ?? docWidth ?? logicalWidth ?? 1)), height: Math.max(1, Number(canvas.getHeight?.() ?? docHeight ?? logicalHeight ?? 1)), view: getViewMetaById(normalizedViewId), isLiveCanvas: true };
  }
  const view = getViewMetaById(normalizedViewId);
  const snap = getSnapshotForView(normalizedViewId) ?? { objects: [] };
  const w = Math.max(1, Number(view?.width ?? canvas.getWidth?.() ?? docWidth ?? logicalWidth ?? 1));
  const h = Math.max(1, Number(view?.height ?? canvas.getHeight?.() ?? docHeight ?? logicalHeight ?? 1));
  const el = document.createElement('canvas');
  el.width = w;
  el.height = h;
  const tmp = new (fabric as any).StaticCanvas(el, { width: w, height: h, renderOnAddRemove: false });
  try {
    await tmp.loadFromJSON(snap);
    const objs = tmp.getObjects?.() ?? [];
    for (const object of objs) {
      if (isExportHelperObject(object)) tmp.remove?.(object);
    }
    await settleFabricCanvas(tmp);
  } catch {}
  return { tmp, width: w, height: h, view, isLiveCanvas: false };
};

const exportViewToDataUrl = async (viewId: string, opts: any = {}): Promise<string> => {
  const normalizedViewId = String(viewId ?? '').trim();
  const forceSnapshot = !!opts?.forceSnapshot;
  if (!normalizedViewId) {
    return await exportCanvasToDataUrl(opts);
  }
  if (!forceSnapshot && normalizedViewId === getLiveActiveViewId()) {
    return await exportCanvasToDataUrl(opts);
  }
  const built = await createStaticCanvasFromSnapshot(normalizedViewId, { forceSnapshot });
  try {
    const multiplier = computeDownloadMultiplier(Math.max(1, built.width), opts);
    return built.tmp.toDataURL({
      format: opts?.format === 'jpg' ? 'jpeg' : (opts?.format || 'png'),
      multiplier,
      filter: (obj: any) => !isExportHelperObject(obj),
    });
  } finally {
    if (!(built as any).isLiveCanvas) {
      try { built.tmp.dispose?.(); } catch {}
    }
  }
};

const serializeViewToSvg = async (viewId: string, opts: any = {}): Promise<string> => {
  const normalizedViewId = String(viewId ?? '').trim();
  const forceSnapshot = !!opts?.forceSnapshot;
  if (!normalizedViewId) {
    return await serializeTargetToSvg(canvas, 'canvas');
  }
  if (!forceSnapshot && normalizedViewId === getLiveActiveViewId()) {
    return await serializeTargetToSvg(canvas, 'canvas');
  }
  const built = await createStaticCanvasFromSnapshot(normalizedViewId, { forceSnapshot });
  try {
    const pngUrl = built.tmp.toDataURL({ format: 'png', multiplier: 1, filter: (obj: any) => !isExportHelperObject(obj) });
    return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${built.width}" height="${built.height}" viewBox="0 0 ${built.width} ${built.height}"><image href="${pngUrl}" width="${built.width}" height="${built.height}" preserveAspectRatio="xMidYMid meet"/></svg>`;
  } finally {
    if (!(built as any).isLiveCanvas) {
      try { built.tmp.dispose?.(); } catch {}
    }
  }
};

const exportViewJson = (viewId: string) => getSnapshotForView(String(viewId ?? '')) ?? { objects: [] };

const renderExportBlob = async (source: any, opts: any = {}) => {
  const format = String(opts?.format ?? 'png').toLowerCase();
  const zones = drawingZones.map((z) => ({ id: z.id, x: z.left, y: z.top, width: z.width, height: z.height, shape: (z as any).shape, sourceSvg: (z as any).sourceSvg, sourcePath: (z as any).sourcePath }));
  const sourceKind = String((source as any)?.__asukaDownloadKind ?? '').trim();
  const isViewSource = !!source && typeof source === 'object' && sourceKind === 'view';
  const isZoneSource = !!source && typeof source === 'object' && (sourceKind === 'zone' || (!sourceKind && 'id' in (source as any) && !('objectId' in (source as any)) && !('viewId' in (source as any))));
  const isObjectSource = !!source && typeof source === 'object' && (sourceKind === 'object' || typeof (source as any).toDataURL === 'function' || typeof (source as any)._asukaObjectId === 'string' || typeof (source as any).objectId === 'string');
  const zoneSource = isZoneSource ? (zones.find((z) => String(z.id) === String((source as any).zoneId ?? (source as any).id ?? '')) ?? null) : null;
  const objectSource = isObjectSource
    ? ((typeof (source as any).toDataURL === 'function' ? source : null)
        ?? ((canvas.getObjects?.() ?? []).find((obj: any) => String((obj as any)?._asukaObjectId ?? '') === String((source as any).objectId ?? (source as any)._asukaObjectId ?? '')) ?? null))
    : null;
  const targetKind = isViewSource ? 'view' : objectSource ? 'object' : zoneSource ? 'zone' : 'canvas';
  const target = isViewSource ? String((source as any).viewId ?? '') : (objectSource ?? zoneSource ?? canvas);
  const mergedOpts = isViewSource && (source as any)?.forceSnapshot ? { ...opts, forceSnapshot: true } : opts;

  if (format === 'json') {
    const payload = targetKind === 'canvas'
      ? canvas.toJSON(EXTRA_SERIALIZE_PROPS)
      : targetKind === 'view'
        ? exportViewJson(String(target))
        : targetKind === 'object'
          ? ((target as any).toObject?.(EXTRA_SERIALIZE_PROPS) ?? (target as any).toObject?.() ?? {})
          : { zone: target };
    return { blob: new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }), ext: 'json', kind: targetKind };
  }

  if (format === 'svg') {
    const svg = targetKind === 'view' ? await serializeViewToSvg(String(target), mergedOpts) : await serializeTargetToSvg(target, targetKind as any);
    return { blob: new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), ext: 'svg', kind: targetKind };
  }

  const rasterDataUrl = targetKind === 'view'
    ? await exportViewToDataUrl(String(target), mergedOpts)
    : targetKind === 'zone'
      ? await exportZoneToDataUrl(target, mergedOpts)
      : targetKind === 'object'
        ? await exportObjectToDataUrl(target, mergedOpts)
        : await exportCanvasToDataUrl(mergedOpts);

  if (format === 'pdf') {
    const marks = mergedOpts?.marks ?? {};
    const marksSizePx = mergedOpts?.cropMark ? ((parseUnitToPx(Number(marks?.offset ?? 0) + Number(marks?.bleed ?? 0) + Number(marks?.margin ?? 0), marks?.unit ?? mergedOpts?.unit ?? 'px') ?? 0)) : 0;
    const viewMeta = targetKind === 'view' ? getViewMetaById(String(target)) : null;
    const widthPx = targetKind === 'zone' ? Number((target as any)?.width ?? 1) : targetKind === 'object' ? Number((target as any)?.getScaledWidth?.() ?? (target as any)?.width ?? 1) : targetKind === 'view' ? Number(viewMeta?.width ?? canvas.getWidth?.() ?? 1) : Number(canvas.getWidth?.() ?? 1);
    const heightPx = targetKind === 'zone' ? Number((target as any)?.height ?? 1) : targetKind === 'object' ? Number((target as any)?.getScaledHeight?.() ?? (target as any)?.height ?? 1) : targetKind === 'view' ? Number(viewMeta?.height ?? canvas.getHeight?.() ?? 1) : Number(canvas.getHeight?.() ?? 1);
    const widthPt = widthPx * 0.75 * computeDownloadMultiplier(Math.max(1, widthPx), mergedOpts);
    const heightPt = heightPx * 0.75 * computeDownloadMultiplier(Math.max(1, widthPx), mergedOpts);
    const marksPt = marksSizePx * 0.75;
    const x = marksPt / 2;
    const y = marksPt / 2;
    const Js = await ensurePdfSupport({ autoLoad: true });
    if (!Js) throw new Error('PDF export support is not available. Load jsPDF first or configure options.pdf.scriptUrl.');
    const pdf = new Js({ orientation: widthPt > heightPt ? 'landscape' : 'portrait', unit: 'pt', format: [widthPt + marksPt, heightPt + marksPt] });
    pdf.addImage(rasterDataUrl, 'PNG', x, y, widthPt, heightPt);
    if (mergedOpts?.cropMark) drawPdfCropMarks(pdf, x, y, widthPt, heightPt);
    const pdfBlob = pdf.output('blob');
    return { blob: pdfBlob, ext: 'pdf', kind: targetKind };
  }

  return { blob: dataUrlToBlob(rasterDataUrl), ext: format === 'jpg' ? 'jpeg' : format, kind: targetKind };
};

const downloadManySources = async (entries: Array<{ source: any; name: string }>, opts: any = {}) => {
  const files: Array<{ name: string; blob: Blob }> = [];
  const errors: Array<{ name: string; error: unknown }> = [];
  for (const entry of entries) {
    try {
      const rendered = await renderExportBlob(entry.source, { ...opts, name: entry.name });
      if (rendered?.blob) files.push({ name: `${sanitizeFilenamePart(entry.name)}.${rendered.ext}`, blob: rendered.blob });
    } catch (error) {
      errors.push({ name: entry.name, error });
      try { void 0; } catch {}
    }
  }
  if (!files.length) {
    const first = errors[0]?.error;
    throw first instanceof Error ? first : new Error('No exportable files were generated');
  }
  const shouldZip = opts?.zip !== false && files.length > 1;
  if (shouldZip) {
    const zipBlob = await makeZipBlob(files);
    saveBlobAs(zipBlob, `${sanitizeFilenamePart(String(opts?.name ?? 'download'))}.zip`);
    return;
  }
  for (const f of files) saveBlobAs(f.blob, f.name);
};

const normalizeDrawingZonesForRuntime = (zones: any[] | null | undefined): AsukaDrawingZone[] => {
  if (!Array.isArray(zones)) return [];
  const byId = new Map<string, AsukaDrawingZone>();
  const order: string[] = [];

  for (const raw of zones) {
    if (!raw || typeof raw !== 'object') continue;
    const id = String(raw.id ?? '').trim();
    if (!id) continue;
    const next: AsukaDrawingZone = {
      id,
      left: Number(raw.left ?? raw.x ?? 0) || 0,
      top: Number(raw.top ?? raw.y ?? 0) || 0,
      width: Math.max(1, Number(raw.width ?? 0) || 1),
      height: Math.max(1, Number(raw.height ?? 0) || 1),
      shape: (raw as any).shape,
      sourceSvg: typeof (raw as any).sourceSvg === 'string' ? String((raw as any).sourceSvg) : undefined,
      sourcePath: typeof (raw as any).sourcePath === 'string' ? String((raw as any).sourcePath) : undefined,
      physicalSize: (raw as any).physicalSize && typeof (raw as any).physicalSize === 'object' ? (raw as any).physicalSize : undefined,
    };
    if (!byId.has(id)) order.push(id);
    byId.set(id, next);
  }

  return order.map((id) => byId.get(id)!).filter(Boolean);
};

const serializeDrawingZonesForState = (zones: any[] | null | undefined = drawingZones) => (
  normalizeDrawingZonesForRuntime(zones).map((z) => ({
    id: z.id,
    x: z.left,
    y: z.top,
    width: z.width,
    height: z.height,
    shape: (z as any).shape,
    sourceSvg: (z as any).sourceSvg,
    sourcePath: (z as any).sourcePath,
    physicalSize: (z as any).physicalSize,
  }))
);

const persistDrawingZonesForActiveView = () => {
  const zonesPayload = serializeDrawingZonesForState(drawingZones);
  const validActiveId = activeDrawingZoneId && zonesPayload.some((z: any) => String(z.id) === String(activeDrawingZoneId))
    ? activeDrawingZoneId
    : null;

  if (activeDrawingZoneId !== validActiveId) activeDrawingZoneId = validActiveId;

  try { useAsukaStore.getState().setDocumentDrawingAreas?.(zonesPayload as any); } catch {}
  try { useAsukaStore.getState().setDocumentSelection?.({ activeDrawingAreaId: validActiveId } as any); } catch {}
  try {
    const viewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
    if (viewId) setSnapshotDrawingAreasForView(viewId, zonesPayload as any, validActiveId);
  } catch {}
};

const isDrawingZonesMutationDenied = (zones: any[] | null | undefined, source: string, bypassPermissions = false): boolean => {
  if (bypassPermissions) return false;
  const nextZones = normalizeDrawingZonesForRuntime(zones);
  const prevCount = Array.isArray(drawingZones) ? drawingZones.length : 0;
  const nextCount = nextZones.length;
  if (nextCount > prevCount && isDesignerPermissionDenied('canCreateDrawingAreas', { action: source, previousCount: prevCount, nextCount })) return true;
  if (nextCount < prevCount && isDesignerPermissionDenied('canDeleteDrawingAreas', { action: source, previousCount: prevCount, nextCount })) return true;
  if (nextCount === prevCount && nextCount > 0 && isDesignerPermissionDenied('canEditDrawingAreas', { action: source, previousCount: prevCount, nextCount })) return true;
  return false;
};

const replaceDrawingZonesFromRuntime = (zones: any[] | null | undefined) => {
  if (isDrawingZonesMutationDenied(zones, 'designer.drawingAreas.runtime.setDrawingZones')) return;
  drawingZones = normalizeDrawingZonesForRuntime(zones);

  if (!drawingZones.length) {
    activeDrawingZoneId = null;
  } else if (activeDrawingZoneId && !drawingZones.some((z) => z.id === activeDrawingZoneId)) {
    activeDrawingZoneId = drawingZones[0]?.id ?? null;
  }

  persistDrawingZonesForActiveView();
  try { syncDocumentSelectionState(canvas, activeDrawingZoneId); } catch {}
  try { drawingAreasLifecycleRuntime.rebuildZoneRuntimeFromCanvas(canvas, drawingZones); } catch {}
};

const setActiveDrawingZoneFromRuntime = (id: string | null) => {
  const wanted = String(id ?? '').trim();
  activeDrawingZoneId = wanted && drawingZones.some((z) => z.id === wanted)
    ? wanted
    : null;
  persistDrawingZonesForActiveView();
  try { syncDocumentSelectionState(canvas, activeDrawingZoneId); } catch {}
};

drawingAreasInteractionRuntime = createDrawingAreasInteractionRuntime({
  canvas,
  fabric,
  getDrawingZones: () => drawingZones,
  setDrawingZones: replaceDrawingZonesFromRuntime,
  getZone,
  getActiveDrawingZoneId: () => activeDrawingZoneId,
  setActiveDrawingZoneId: setActiveDrawingZoneFromRuntime,
  getZonePickActive: () => __zonePickActive,
  setZonePickActive: (value: boolean) => { __zonePickActive = !!value; },
  getZonePickResolve: () => __zonePickResolve,
  setZonePickResolve: (fn: ((id: string) => void) | null) => { __zonePickResolve = fn; },
  getZonePickReject: () => __zonePickReject,
  setZonePickReject: (fn: ((err: any) => void) | null) => { __zonePickReject = fn; },
  getZonePickRects: () => __zonePickRects,
  setZonePickRects: (rects: any[]) => { __zonePickRects = Array.isArray(rects) ? rects : []; },
  getPendingAddZoneId: () => __pendingAddZoneId,
  setPendingAddZoneId: (id: string | null) => { __pendingAddZoneId = id; },
  getActiveDrawingAreaDraftObjectId: () => __activeDrawingAreaDraftObjectId,
  setActiveDrawingAreaDraftObjectId: (id: string | null) => { __activeDrawingAreaDraftObjectId = id; },
  isDrawingAreaDraft,
  getObjectWorldBounds,
  getRuntimeObjectByIdFast,
  emitAsukaEvent,
  syncDrawingZoneVisuals,
  scheduleHistorySnapshot,
  createZoneClipObjectSync,
  normalizeSvgObjectToRect,
});


const instance: DesignerInstance = {

  refreshAfterViewLoad() {
    try { rendererLifecycleRuntime?.refreshAfterViewLoad?.(); } catch {}
  },


// --- Drawing zones API (F4/F5) ---
setDrawingZones(zones: Array<{ id: string; x: number; y: number; width: number; height: number; physicalSize?: import('./physicalMeasurement').AsukaPhysicalSize }>, options?: { bypassPermissions?: boolean; source?: string }) {
  if (isDrawingZonesMutationDenied(zones, String(options?.source ?? 'designer.setDrawingZones'), Boolean(options?.bypassPermissions))) return;
  drawingZones = normalizeDrawingZonesForRuntime(zones);

  // IMPORTANT: no implicit default drawing zone.
  // Drawing areas are view-specific structural objects; if none are defined, keep the list empty.
  if (!drawingZones.length) {
    activeDrawingZoneId = null;
    emitAsukaEvent('drawingZones.activeChanged', { id: activeDrawingZoneId });
  }

  if (activeDrawingZoneId && !drawingZones.find((z) => z.id === activeDrawingZoneId)) {
    activeDrawingZoneId = drawingZones[0]?.id ?? null;
    emitAsukaEvent('drawingZones.activeChanged', { id: activeDrawingZoneId });
  }


  // If the drawing zones changed while a pick overlay is visible (e.g. the user just created/edited a zone),
  // force-hide the picker. Zone picking is only intended for "add object" flows.
  try {
    if (__zonePickActive || (__zonePickRects && __zonePickRects.length)) {
      __zonePickActive = false;
      __zonePickResolve = null;
      __zonePickReject = null;
      __pendingAddZoneId = null;
      clearZonePickRects();
    }
  } catch {}
const __zonesPayload = drawingZones.map((z) => ({ id: z.id, x: z.left, y: z.top, width: z.width, height: z.height, shape: (z as any).shape, sourceSvg: (z as any).sourceSvg, sourcePath: (z as any).sourcePath, physicalSize: (z as any).physicalSize }));
  try { useAsukaStore.getState().setDocumentDrawingAreas?.(__zonesPayload as any); } catch {}
  try {
    const viewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
    if (viewId) setSnapshotDrawingAreasForView(viewId, __zonesPayload as any, activeDrawingZoneId);
  } catch {}
  try { syncDocumentSelectionState(canvas, activeDrawingZoneId); } catch {}
  emitAsukaEvent('drawingZones.updated', {
    zones: __zonesPayload,
  });

  // Rebuild runtime indices (multi-zones) + placeholders
  try { drawingAreasLifecycleRuntime.rebuildZoneRuntimeFromCanvas(canvas, drawingZones); } catch {}

  syncDrawingZoneVisuals();
    // Re-apply custom controls/icons after view load (objects are rehydrated).
    try {
      const objs = this.canvas.getObjects?.() ?? [];
      if (Array.isArray(objs)) {
        objs.forEach((o: any) => {
        // Only apply controls to user objects (avoid tools/helpers)
        const role = (o as any)?._asukaRole ?? 'user';
        const type = (o as any)?.type;
        // Always sanitize per-object controls map to avoid Fabric drawControls crashes after loadFromJSON.
        try {
          const c = (o as any).controls;
          if (c && typeof c === 'object') {
            Object.keys(c).forEach((k: string) => {
              const v = c[k];
              if (!v || typeof v !== 'object' || typeof v.x !== 'number' || typeof v.y !== 'number') {
                try { delete c[k]; } catch {}
              }
            });
          }
        } catch {}
        if (role !== 'user' || type === 'activeSelection') return;
        applyAsukaControlsDeep(o);
        if (ASUKA_CONTROLS_DEBUG) void 0;
      });
      }
      const active = this.canvas.getActiveObject?.();
      if (active) applyAsukaControlsDeep(active);
    } catch {}

},

getDrawingZones() {
  return drawingZones.map((z) => ({ id: z.id, x: z.left, y: z.top, width: z.width, height: z.height, shape: (z as any).shape, sourceSvg: (z as any).sourceSvg, sourcePath: (z as any).sourcePath }));
},

setActiveDrawingZone(id: string | null) {
  activeDrawingZoneId = id;
  if (activeDrawingZoneId && !drawingZones.find((z) => z.id === activeDrawingZoneId)) {
    activeDrawingZoneId = drawingZones[0]?.id ?? null;
  }
  emitAsukaEvent('drawingZones.activeChanged', { id: activeDrawingZoneId });
  try { syncDocumentSelectionState(canvas, activeDrawingZoneId); } catch {}
  syncDrawingZoneVisuals();
},


getActiveDrawingZone() {
  return activeDrawingZoneId;
},

async createDrawingAreaDraft(options: DesignerDrawingAreaDraftOptions) {
  if (!options || isDesignerPermissionDenied('canCreateDrawingAreas', { action: 'designer.createDrawingAreaDraft', kind: options?.kind })) return null;
  const kind = options.kind;
  const canvasWidth = Math.max(1, Number(canvas.getWidth?.() ?? 800) || 800);
  const canvasHeight = Math.max(1, Number(canvas.getHeight?.() ?? 600) || 600);
  let viewportCenter = { x: canvasWidth / 2, y: canvasHeight / 2 };
  try {
    const inverse = fabric.util.invertTransform(canvas.viewportTransform);
    const point = fabric.util.transformPoint(new fabric.Point(viewportCenter.x, viewportCenter.y), inverse);
    viewportCenter = { x: point.x, y: point.y };
  } catch {}
  const fallbackWidth = kind === 'triangle' ? 220 : 220;
  const fallbackHeight = kind === 'triangle' ? 180 : 160;
  const bounds = options.bounds
    ? {
        left: Number(options.bounds.left ?? 0) || 0,
        top: Number(options.bounds.top ?? 0) || 0,
        width: Math.max(1, Number(options.bounds.width ?? 1) || 1),
        height: Math.max(1, Number(options.bounds.height ?? 1) || 1),
      }
    : {
        left: viewportCenter.x - fallbackWidth / 2,
        top: viewportCenter.y - fallbackHeight / 2,
        width: fallbackWidth,
        height: fallbackHeight,
      };
  const common: any = {
    left: bounds.left + bounds.width / 2,
    top: bounds.top + bounds.height / 2,
    fill: 'rgba(59,130,246,0.08)',
    stroke: '#3b82f6',
    strokeWidth: 2,
    strokeDashArray: [6, 4],
    originX: 'center',
    originY: 'center',
    objectCaching: true,
  };
  const makeStarPoints = (spikes: number, outerRadius: number, innerRadius: number) => {
    const points: Array<{ x: number; y: number }> = [];
    let angle = (Math.PI / 2) * 3;
    const step = Math.PI / spikes;
    for (let index = 0; index < spikes; index += 1) {
      points.push({ x: Math.cos(angle) * outerRadius, y: Math.sin(angle) * outerRadius });
      angle += step;
      points.push({ x: Math.cos(angle) * innerRadius, y: Math.sin(angle) * innerRadius });
      angle += step;
    }
    return points;
  };

  let object: any = null;
  if (kind === 'rect') object = new fabric.Rect({ ...common, width: bounds.width, height: bounds.height, rx: 12, ry: 12 });
  else if (kind === 'ellipse') object = new fabric.Ellipse({ ...common, rx: bounds.width / 2, ry: bounds.height / 2 });
  else if (kind === 'triangle') object = new fabric.Triangle({ ...common, width: bounds.width, height: bounds.height });
  else if (kind === 'star') {
    const outerRadius = Math.max(1, Math.min(bounds.width, bounds.height) / 2);
    object = new fabric.Polygon(makeStarPoints(5, outerRadius, outerRadius * 0.45), {
      ...common,
      scaleX: bounds.width / Math.max(outerRadius * 2, 1),
      scaleY: bounds.height / Math.max(outerRadius * 2, 1),
    });
  } else if (kind === 'svg') {
    const svgText = String(options.svgText ?? '').trim();
    if (!svgText) return null;
    let parsed: any = null;
    try { parsed = await (fabric as any).loadSVGFromString?.(svgText); } catch {}
    let objects: any[] = [];
    let svgOptions: any = {};
    if (Array.isArray(parsed)) {
      objects = Array.isArray(parsed[0]) ? parsed[0].filter(Boolean) : (parsed[0] ? [parsed[0]] : []);
      svgOptions = parsed[1] ?? {};
    } else if (parsed && typeof parsed === 'object') {
      objects = Array.isArray(parsed.objects) ? parsed.objects.filter(Boolean) : (parsed.objects ? [parsed.objects] : []);
      svgOptions = parsed.options ?? {};
    }
    if (!objects.length) return null;
    const groupSvg = (fabric as any).util?.groupSVGElements;
    if (objects.length === 1) object = objects[0];
    else if (typeof groupSvg === 'function') object = groupSvg(objects, svgOptions);
    else if (typeof (fabric as any).Group === 'function') object = new (fabric as any).Group(objects, svgOptions);
    if (!object) return null;
    object.set?.({ left: common.left, top: common.top, originX: 'center', originY: 'center', opacity: 0.95 });
    object.setCoords?.();
    if (!options.bounds) {
      const objectBounds = object.getBoundingRect?.(true, true);
      if (Number(objectBounds?.width) > 260) object.scaleToWidth?.(260);
    } else {
      const objectBounds = object.getBoundingRect?.(true, true) ?? { width: 1, height: 1 };
      const scale = Math.min(bounds.width / Math.max(1, Number(objectBounds.width) || 1), bounds.height / Math.max(1, Number(objectBounds.height) || 1));
      object.set?.({ scaleX: (Number(object.scaleX ?? 1) || 1) * scale, scaleY: (Number(object.scaleY ?? 1) || 1) * scale });
    }
  }
  if (!object) return null;
  try {
    object._asukaDraftDrawingArea = true;
    object._asukaIgnoreDrawingZones = true;
    object._asukaRole = 'drawingAreaDraft';
    object.itemGroup = 'Tools';
    object.clipPath = null;
  } catch {}
  try { prepareExternalObject(object); } catch {}
  try { canvas.add(object); } catch { return null; }
  try {
    (instance as any)._asukaPrepareDrawingAreaDraft?.(object, {
      idPrefix: String(options.idPrefix ?? 'area'),
      shape: kind,
      ...(kind === 'svg' ? { sourceSvg: String(options.svgText ?? '') } : null),
    });
  } catch {}
  try { canvas.setActiveObject?.(object); } catch {}
  try { canvas.requestRenderAll?.(); } catch { try { canvas.renderAll?.(); } catch {} }
  return object;
},

commitDrawingAreaDraft(objectId: string) {
  if (isDesignerPermissionDenied('canCreateDrawingAreas', { action: 'designer.commitDrawingAreaDraft', objectId })) return null;
  return finalizeDrawingAreaDraftById(String(objectId ?? '').trim());
},

startDrawingAreaDraft(options: DesignerDrawingAreaDraftOptions, onComplete?: (object: any) => void) {
  try { __drawingAreaDraftInteractionCleanup?.(); } catch {}
  __drawingAreaDraftInteractionCleanup = null;
  if (!options || options.kind === 'svg' || isDesignerPermissionDenied('canCreateDrawingAreas', { action: 'designer.startDrawingAreaDraft', kind: options?.kind })) return () => {};
  let start: { x: number; y: number } | null = null;
  let preview: any = null;
  const pointer = (event: any) => {
    try { return getCanvasPointer(event?.e ?? event); } catch { return { x: 0, y: 0 }; }
  };
  const toBounds = (from: { x: number; y: number }, to: { x: number; y: number }) => ({
    left: Math.min(from.x, to.x),
    top: Math.min(from.y, to.y),
    width: Math.max(1, Math.abs(to.x - from.x)),
    height: Math.max(1, Math.abs(to.y - from.y)),
  });
  const makePreview = (bounds: { left: number; top: number; width: number; height: number }) => {
    const common: any = {
      left: bounds.left + bounds.width / 2,
      top: bounds.top + bounds.height / 2,
      fill: 'rgba(59,130,246,0.08)',
      stroke: '#3b82f6',
      strokeWidth: 2,
      strokeDashArray: [6, 4],
      originX: 'center',
      originY: 'center',
      selectable: false,
      evented: false,
    };
    if (options.kind === 'rect') return new fabric.Rect({ ...common, width: bounds.width, height: bounds.height, rx: 12, ry: 12 });
    if (options.kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: bounds.width / 2, ry: bounds.height / 2 });
    if (options.kind === 'triangle') return new fabric.Triangle({ ...common, width: bounds.width, height: bounds.height });
    const radius = Math.max(1, Math.min(bounds.width, bounds.height) / 2);
    const points: Array<{ x: number; y: number }> = [];
    let angle = (Math.PI / 2) * 3;
    for (let index = 0; index < 5; index += 1) {
      points.push({ x: Math.cos(angle) * radius, y: Math.sin(angle) * radius });
      angle += Math.PI / 5;
      points.push({ x: Math.cos(angle) * radius * 0.45, y: Math.sin(angle) * radius * 0.45 });
      angle += Math.PI / 5;
    }
    return new fabric.Polygon(points, { ...common, scaleX: bounds.width / Math.max(radius * 2, 1), scaleY: bounds.height / Math.max(radius * 2, 1) });
  };
  const cleanup = () => {
    try { canvas.off?.('mouse:down', onDown); canvas.off?.('mouse:move', onMove); canvas.off?.('mouse:up', onUp); } catch {}
    try { if (preview) canvas.remove(preview); } catch {}
    preview = null;
    start = null;
    if (__drawingAreaDraftInteractionCleanup === cleanup) __drawingAreaDraftInteractionCleanup = null;
    try { canvas.requestRenderAll?.(); } catch {}
  };
  const onDown = (event: any) => {
    const point = pointer(event);
    start = { x: point.x, y: point.y };
    preview = makePreview({ left: point.x, top: point.y, width: 1, height: 1 });
    if (preview) canvas.add(preview);
  };
  const onMove = (event: any) => {
    if (!start) return;
    const bounds = toBounds(start, pointer(event));
    try { if (preview) canvas.remove(preview); } catch {}
    preview = makePreview(bounds);
    if (preview) canvas.add(preview);
    canvas.requestRenderAll?.();
  };
  const onUp = (event: any) => {
    if (!start) return;
    const bounds = toBounds(start, pointer(event));
    cleanup();
    void (instance as any).createDrawingAreaDraft({ ...options, bounds }).then((object: any) => onComplete?.(object)).catch(() => onComplete?.(null));
  };
  canvas.on?.('mouse:down', onDown);
  canvas.on?.('mouse:move', onMove);
  canvas.on?.('mouse:up', onUp);
  __drawingAreaDraftInteractionCleanup = cleanup;
  return cleanup;
},

_asukaPrepareDrawingAreaDraft(obj: any, meta: any = {}) {
  if (isDesignerPermissionDenied('canCreateDrawingAreas', { action: 'designer.prepareDrawingAreaDraft', meta })) return null;
  if (!obj) return null;
  try { ensureObjectId(obj); } catch {}
  try {
    (obj as any)._asukaDraftDrawingArea = true;
    (obj as any)._asukaDraftDrawingAreaId = String(meta?.id || `${meta?.idPrefix || 'zone'}-${Date.now()}`);
    (obj as any)._asukaDraftDrawingAreaShape = String(meta?.shape || 'rect');
    if (typeof meta?.sourceSvg === 'string') (obj as any)._asukaDraftDrawingAreaSvg = meta.sourceSvg;
    if (typeof meta?.sourcePath === 'string') (obj as any)._asukaDraftDrawingAreaPath = meta.sourcePath;
    (obj as any)._asukaRole = 'drawingAreaDraft';
    (obj as any)._asukaIgnoreDrawingZones = true;
    (obj as any).itemGroup = 'Tools';
    obj.set?.({ selectable: true, evented: true, hasControls: true, hasBorders: true, lockMovementX: false, lockMovementY: false });
    obj.setCoords?.();
    canvas.setActiveObject?.(obj);
    __activeDrawingAreaDraftObjectId = String((obj as any)._asukaObjectId ?? ensureObjectId(obj));
    canvas.requestRenderAll?.();
  } catch {}
  return obj;
},

_finalizeActiveDrawingAreaDraft() {
  if (isDesignerPermissionDenied('canCreateDrawingAreas', { action: 'designer.finalizeDrawingAreaDraft' })) return null;
  if (!__activeDrawingAreaDraftObjectId) return null;
  return finalizeDrawingAreaDraftById(__activeDrawingAreaDraftObjectId);
},

// --- Drawing areas policies/runtime (multi-zones) ---
setDrawingAreaPolicy(id: string, patch: DrawingAreaPolicy) {
  if (isDesignerPermissionDenied('canEditDrawingAreas', { action: 'designer.setDrawingAreaPolicy', id, patch })) return;
  if (!id) return;
  const prev = drawingAreaPolicies.get(id) ?? {};
  const next: DrawingAreaPolicy = { ...prev, ...(patch ?? {}) } as any;
  drawingAreaPolicies.set(id, next);
  emitAsukaEvent('drawingAreas.policy.updated', { areaId: id, policy: next });
  try { ensureZonePlaceholder(id); } catch {}
  try { drawingAreasLifecycleRuntime.reapplyClipPathsForZone(canvas, id); } catch {}
},

getDrawingAreaPolicy(id: string) {
  return drawingAreaPolicies.get(id) ?? {};
},

getDrawingAreaPolicies() {
  const out: Record<string, DrawingAreaPolicy> = {};
  for (const [k, v] of drawingAreaPolicies.entries()) out[k] = v;
  return out;
},

getDrawingAreaRuntime(id: string) {
  const rt = drawingAreaRuntime.get(id);
  return rt ? { objectIds: [...rt.objectIds], placeholderId: rt.placeholderId } : { objectIds: [], placeholderId: undefined };
},


getDrawingAreaPriceUiBreakdown(id: string, externalPrice?: number | string) {
  return computeDrawingAreaPriceUiBreakdown(id, externalPrice);
},

getDrawingAreaPrice(id: string, externalPrice?: number | string) {
  const b = computeDrawingAreaPriceUiBreakdown(id, externalPrice) as any;
  return Number(b?.total ?? 0);
},


// If multiple zones exist, highlight them and let the user click one.
async pickDrawingZoneForAdd() {
  if (isDesignerPermissionDenied('canViewDrawingAreas', { action: 'designer.pickDrawingZoneForAdd' })) return String(activeDrawingZoneId ?? getZone().id ?? 'canvas');
  return await ensureZoneForAdd();
},

setLayerIndex(object: any, index: number) {
  if (isDesignerPermissionDenied('canReorderLayers', { action: 'designer.setLayerIndex', objectId: object?._asukaObjectId ?? object?.id, index })) return;
  if (!object || typeof index !== 'number') return;
  try {
    (object as any).layerIndex = index;
    ensureOrderingSeq(object, index);
    scheduleNormalizeLayers({ assignMissingOnly: false, canvasRef: canvas });
  } catch {}
},

getLayerIndex(object: any): number | null {
  return typeof (object as any)?.layerIndex === 'number' ? (object as any).layerIndex : null;
},

normalizeLayers() {
  if (isDesignerPermissionDenied('canReorderLayers', { action: 'designer.normalizeLayers' })) return;
  normalizeLayersByRules({ assignMissingOnly: false });
},

reorderObjects(objectIds: string[], opts?: { order?: 'canvas' | 'display' }) {
  if (isDesignerPermissionDenied('canReorderLayers', { action: 'designer.reorderObjects', objectIds, opts })) return false;
  const ids = Array.isArray(objectIds)
    ? objectIds.map((id) => String(id ?? '').trim()).filter(Boolean)
    : [];
  if (!ids.length) return false;

  const orderMode = opts?.order === 'display' ? 'display' : 'canvas';
  const canvasOrderIds = orderMode === 'display' ? ids.slice().reverse() : ids.slice();
  const activeViewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
  if (!activeViewId) return false;

  const runtimeObjects = (canvas.getObjects?.() ?? []).filter((obj: any) => isUserLayerRuntimeObject(obj));
  const runtimeById = new Map(runtimeObjects.map((obj: any) => [String(ensureObjectId(obj)), obj]));
  const orderedRuntime = canvasOrderIds.map((id) => runtimeById.get(id)).filter(Boolean) as any[];
  if (!orderedRuntime.length || orderedRuntime.length !== runtimeObjects.length) return false;

  const allObjects = canvas.getObjects?.() ?? [];
  const userSlots = allObjects
    .map((obj: any, index: number) => (isUserLayerRuntimeObject(obj) ? index : -1))
    .filter((index: number) => index >= 0);

  orderedRuntime.forEach((obj: any, idx: number) => {
    try { moveObjectToIndex(obj, userSlots[idx], canvas); } catch {}
  });

  const finalObjects = canvas.getObjects?.() ?? [];
  finalObjects.forEach((obj: any, index: number) => {
    try {
      (obj as any).layerIndex = index;
      ensureOrderingSeq(obj, index);
      obj.setCoords?.();
    } catch {}
  });

  try { canvas.requestRenderAll?.(); } catch {}
  try { syncDocumentObjectsForView(activeViewId); } catch {}
  try { scheduleHistorySnapshot({ force: true }); } catch {}
  return true;
},

previewObject(objectId: string | null, opts?: { mode?: 'fade-others' | 'outline'; dimOpacity?: number }) {
  previewObjectOnCanvas(objectId, opts);
},

clearObjectPreview() {
  clearObjectPreviewState();
},

addShapeSvgAsset(payload: { id?: string; name: string; svgText: string }) {
  return useAsukaStore.getState().addShapeSvgAsset?.(payload as any);
},

removeShapeSvgAsset(id: string) {
  useAsukaStore.getState().removeShapeSvgAsset?.(id);
},

getShapeSvgAssets() {
  return (useAsukaStore.getState().shapeSvgAssets ?? []) as any[];
},

setShapeStyle(patch: { fill?: string; stroke?: string; strokeWidth?: number; opacity?: number }) {
  useAsukaStore.getState().setShapeStyle?.(patch as any);
},

setShapeDefaults(patch: { rectRadius?: number; starSpikes?: number; starInnerRatio?: number }) {
  useAsukaStore.getState().setShapeEditDefaults?.(patch as any);
},

addSystemFont(payload: string | { id?: string; family: string }) {
  const nextFont = normalizeSystemFont(payload as any);
  let createdId = nextFont.id;
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: dedupeFonts([...(state?.fontsCatalog ?? []), nextFont]),
  }));
  return createdId;
},

async addGoogleFont(payload: string | { id?: string; family: string; cssUrl?: string; weights?: Array<string | number>; styles?: Array<'normal' | 'italic'> }) {
  const nextFont = normalizeGoogleFont(payload as any);
  const id = nextFont.id;
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: dedupeFonts([...(state?.fontsCatalog ?? []), { ...nextFont, loading: true, error: undefined }]),
  }));
  const font = ((useAsukaStore.getState() as any).fontsCatalog ?? []).find((entry: any) => String(entry?.id) === String(id)) ?? nextFont;
  const loaded = await loadRegisteredFont(font as any);
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) =>
      String(entry?.id) === String(id)
        ? { ...entry, loading: false, loaded: !!loaded, error: loaded ? undefined : 'Failed to load font' }
        : entry,
    ),
  }));
  return id;
},

async addCustomFont(payload: { id?: string; family: string; url?: string; cssUrl?: string; format?: 'woff2' | 'woff' | 'ttf' | 'otf'; weight?: string | number; style?: 'normal' | 'italic' }) {
  const nextFont = normalizeCustomFont(payload as any);
  const id = nextFont.id;
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: dedupeFonts([...(state?.fontsCatalog ?? []), { ...nextFont, loading: true, error: undefined }]),
  }));
  const font = ((useAsukaStore.getState() as any).fontsCatalog ?? []).find((entry: any) => String(entry?.id) === String(id)) ?? nextFont;
  const loaded = await loadRegisteredFont(font as any);
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) =>
      String(entry?.id) === String(id)
        ? { ...entry, loading: false, loaded: !!loaded, error: loaded ? undefined : 'Failed to load font' }
        : entry,
    ),
  }));
  return id;
},

removeFont(id: string) {
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: (state?.fontsCatalog ?? []).filter((entry: any) => String(entry?.id) !== String(id)),
  }));
},

getFonts() {
  return (useAsukaStore.getState() as any).fontsCatalog ?? [];
},

async loadFont(id: string) {
  const store: any = useAsukaStore.getState();
  const font = (store.fontsCatalog ?? []).find((entry: any) => String(entry?.id) === String(id));
  if (!font) return false;
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) =>
      String(entry?.id) === String(id) ? { ...entry, loading: true, error: undefined } : entry,
    ),
  }));
  const loaded = await loadRegisteredFont(font as any);
  (useAsukaStore as any).setState?.((state: any) => ({
    fontsCatalog: (state?.fontsCatalog ?? []).map((entry: any) =>
      String(entry?.id) === String(id)
        ? { ...entry, loading: false, loaded: !!loaded, error: loaded ? undefined : 'Failed to load font' }
        : entry,
    ),
  }));
  return loaded;
},

    getCanvas() {
      return canvas;
    },

    async exportPngBlob(opts: any = {}) {
      const viewId = String(opts?.viewId ?? '').trim();
      const dataUrl = viewId
        ? await exportViewToDataUrl(viewId, { ...opts, format: 'png' })
        : await exportCanvasToDataUrl({ ...opts, format: 'png' });
      if (!dataUrl) throw new Error('Fabric canvas cannot export PNG in this environment.');
      return dataUrlToBlob(dataUrl);
    },


    getExportableObjects() {
      try {
        const activeViewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
        const records = getDocumentObjectsForView(activeViewId);
        const ids = records
          .map((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim())
          .filter(Boolean);
        const runtimeObjects = ids
          .map((objectId: string) => getRuntimeObjectById(objectId))
          .filter((obj: any) => !!obj && !isExportHelperObject(obj));
        return runtimeObjects;
      } catch {
        return [];
      }
    },

    isPdfAvailable() {
      return isPdfAvailable();
    },

    canLoadPdfSupport() {
      return canLoadPdfSupport();
    },

    async ensurePdfSupport() {
      return !!(await ensurePdfSupport({ autoLoad: true }));
    },

    async download(source?: any, opts: any = {}) {
      if (isDesignerPermissionDenied('canDownload', { action: 'designer.download', sourceKind: (source as any)?.__asukaDownloadKind ?? null })) return;
      if ((source as any)?.__asukaDownloadKind === 'view' && isDesignerPermissionDenied('canDownloadCurrentView', { action: 'designer.download', sourceKind: 'view' })) return;
      const rendered = await renderExportBlob(source ?? canvas, opts ?? {});
      emitAsukaEvent('download', { format: String(opts?.format ?? 'png').toLowerCase(), name: String(opts?.name ?? 'download'), width: opts?.width ?? null, cropMark: !!opts?.cropMark, unit: opts?.unit ?? 'px', marks: opts?.marks ?? null, dpi: opts?.dpi ?? 300 });
      saveBlobAs(rendered.blob, `${sanitizeFilenamePart(String(opts?.name ?? 'download'))}.${rendered.ext}`);
    },

    async downloadAllViews(opts: any = {}) {
      if (isDesignerPermissionDenied('canDownload', { action: 'designer.downloadAllViews' })) return;
      if (isDesignerPermissionDenied('canDownloadAllViews', { action: 'designer.downloadAllViews' })) return;
      try { saveSnapshot({ force: true }); } catch {}
      const views = ((useAsukaStore.getState() as any).views ?? []) as any[];
      const entries = views.map((view: any) => ({
        source: { __asukaDownloadKind: 'view', viewId: String(view?.id ?? ''), forceSnapshot: true },
        name: sanitizeFilenamePart(String(view?.name ?? view?.id ?? 'view')),
      }));
      await downloadManySources(entries, { ...opts, forceSnapshot: true, name: opts?.name ?? 'views' });
    },

    async downloadAllDrawingZones(opts: any = {}) {
      if (isDesignerPermissionDenied('canDownload', { action: 'designer.downloadAllDrawingZones' })) return;
      if (isDesignerPermissionDenied('canDownloadDrawingZones', { action: 'designer.downloadAllDrawingZones' })) return;
      const zones = drawingZones.map((z) => ({ id: z.id, x: z.left, y: z.top, width: z.width, height: z.height, shape: (z as any).shape, sourceSvg: (z as any).sourceSvg, sourcePath: (z as any).sourcePath }));
      const entries = zones.map((zone) => ({ source: zone, name: sanitizeFilenamePart(String(zone.id || 'drawing-area')) }));
      await downloadManySources(entries, { ...opts, name: opts?.name ?? 'drawing-areas' });
    },

    async downloadAllObjects(opts: any = {}) {
      if (isDesignerPermissionDenied('canDownload', { action: 'designer.downloadAllObjects' })) return;
      if (isDesignerPermissionDenied('canDownloadObjects', { action: 'designer.downloadAllObjects' })) return;
      const objs = instance.getExportableObjects();
      const entries = objs.map((obj: any, index: number) => {
        const label = sanitizeFilenamePart(String((obj as any)._asukaElementName || (obj as any).text || (obj as any)._asukaObjectId || (obj as any).id || `object-${index + 1}`));
        return { source: obj, name: label };
      });
      await downloadManySources(entries, { ...opts, name: opts?.name ?? 'objects' });
    },

    // --- Layers/Object API -------------------------------------------------
    // These helpers are used by the Layers panel and can also be used by
    // integrators through the public API.
    // Objects are identified via the session-stable _asukaObjectId.

    addObject(object: any, options?: any) {
      if (!object) return null;
      const meta = options && typeof options === 'object' ? options : {};
      if (meta.__skipPermissionCheck !== true) {
        const group = String(meta.itemGroup ?? (object as any)?.itemGroup ?? '').trim().toLowerCase();
        const key: keyof AsukaPermissions = group === 'images' ? 'canAddImage' : group === 'designs' || group === 'design' ? 'canAddDesigns' : group === 'text' ? 'canAddText' : 'canAddShape';
        if (isDesignerPermissionDenied(key, { action: 'designer.addObject', itemGroup: meta.itemGroup ?? (object as any)?.itemGroup, objectType: (object as any)?.type })) return null;
      }
      const targetViewId = String(meta.viewId ?? useAsukaStore.getState?.().activeViewId ?? '').trim();
      if (!targetViewId) return null;
      try {
        ensureHistoryBaselineBeforeMutation(`add-object:${String(meta.itemGroup ?? (object as any)?.itemGroup ?? (object as any)?.type ?? 'unknown')}`);
      } catch {}
      ensureObjectId(object);
      if (meta.itemGroup != null) {
        // Apply itemGroup before prepareExternalObject(): preparation may auto-fit
        // content into a canvas/drawing-area container, and SVG design groups need
        // their group metadata available before that scope check runs.
        try { (instance as any).setItemGroup?.(object, meta.itemGroup); } catch { (object as any).itemGroup = meta.itemGroup; }
      }
      if (String((object as any).itemGroup ?? '').trim().toLowerCase() === 'designs') {
        try { (object as any)._asukaDesign = true; } catch {}
      }
      const normalizedMetaPrice = normalizePriceInput(meta.price);
      if (typeof normalizedMetaPrice === 'number' && Number.isFinite(normalizedMetaPrice) && (object as any).price == null) {
        try { (object as any).price = normalizedMetaPrice; } catch {}
      }
      try {
        if (meta.prepareObject !== false) prepareExternalObject(object);
      } catch {}
      asukaEnsureCenterTransformOrigin(object);
      if (!((object as any)._asukaElementIsInstance || (object as any)._asukaElementName)) {
        try { asukaApplyControls(object); } catch {}
      }
      if ((object as any)._asukaIgnoreDrawingZones !== true) {
        (object as any)._asukaZoneId = (object as any)._asukaZoneId ?? getZone().id;
        try { applyClipForObject(object); } catch {}
      }
      if (meta.itemGroup != null && (object as any).itemGroup == null) {
        try { (instance as any).setItemGroup?.(object, meta.itemGroup); } catch { (object as any).itemGroup = meta.itemGroup; }
      }

      const activeViewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
      const isActiveViewTarget = activeViewId && activeViewId === targetViewId;

      const assignTopLayerIndex = (target: any, list: any[]) => {
        const maxLayerIndex = list.reduce((max: number, item: any) => {
          const value = Number((item as any)?.layerIndex);
          return Number.isFinite(value) ? Math.max(max, value) : max;
        }, -1);
        const nextLayerIndex = Math.max(0, maxLayerIndex + 1);
        (target as any).layerIndex = nextLayerIndex;
        ensureOrderingSeq(target, nextLayerIndex);
      };

      if (isActiveViewTarget) {
        try { assignTopLayerIndex(object, (canvas.getObjects?.() ?? []).filter((item: any) => isUserLayerRuntimeObject(item))); } catch {}
        try { canvas.add(object); } catch { return null; }
        try { normalizeLayersByRules({ assignMissingOnly: false, canvasRef: canvas }); } catch {}
        try { syncDocumentObjectsForView(targetViewId); } catch {}

        const runtimeObject = getRuntimeObjectById(ensureObjectId(object)) ?? object;
        if (meta.setActive !== false) {
          try { canvas.setActiveObject?.(runtimeObject); } catch {}
          try { syncDocumentSelectionState(canvas, (useAsukaStore.getState() as any)?.documentSelection?.activeDrawingAreaId ?? null); } catch {}
        }
        if (meta.commitHistory !== false) {
          try { scheduleHistorySnapshot({ force: true }); } catch {}
        }
        return runtimeObject;
      }

      const current = Array.isArray(getDocumentObjectsState()[targetViewId]) ? getDocumentObjectsState()[targetViewId] : [];
      try { assignTopLayerIndex(object, current); } catch {}
      const serialized = serializeDocumentObject(object);
      if (!serialized) return null;
      setDocumentObjectsForViewState(targetViewId, [...current, serialized]);
      if (meta.commitHistory !== false) {
        try { scheduleHistorySnapshot({ force: true }); } catch {}
      }
      return object;
    },

    updateObject(objectId: string, patch: Record<string, any>) {
      if (!objectId || !patch || typeof patch !== 'object') return null as any;
      if (isDesignerPatchDenied(patch, { action: 'designer.updateObject', objectId })) return null as any;
      ensureHistoryBaselineBeforeMutation(`update-object:${objectId}`);
      const activeTrackBeforeMutation = getHistoryTrack(getHistoryViewKey());
      const shouldCommitInitialMutationImmediately = !isHistoryMuted()
        && activeTrackBeforeMutation.stack.length === 1
        && activeTrackBeforeMutation.index === 0;
      const nextPatch = { ...patch };
      delete (nextPatch as any).__skipPermissionCheck;
      if (nextPatch.fontFamily) Object.assign(nextPatch, getFontMetadataPatchForFamily(String(nextPatch.fontFamily), patch));
      let obj: any = getRuntimeObjectById(objectId);
      if (obj) {
        try {
          if (typeof obj.set === 'function') obj.set(nextPatch);
          else Object.assign(obj, nextPatch);
        } catch {
          try { Object.assign(obj, nextPatch); } catch {}
        }
      } else {
        obj = patchDocumentObject(objectId, nextPatch, { rebuild: true });
      }
      if (obj) {
        try {
          applyFontMetadataToObject(obj, nextPatch.fontFamily);
          if (shouldAutoSizeTextboxForPatch(nextPatch)) autoSizeTextboxToContent(obj);
          const fixedConstraints: any = (obj as any)?._asukaFixedSlotConstraints ?? (obj as any)?._asukaFixedConstraints;
          if (fixedConstraints) {
            applyFixedConstraintsToObject(obj, fixedConstraints);
          }
          obj.setCoords?.();
          obj.dirty = true;
          try { applyClipForObject(obj); } catch {}
          if (getRuntimeObjectById(objectId) === obj) {
            try { replaceDocumentObjectFromRuntime(objectId, obj, { rebuild: false }); } catch {}
          }
          try { canvas.requestRenderAll?.(); } catch {}
        } catch {}
      }
      if (obj) {
        try {
          if (shouldCommitInitialMutationImmediately) {
            const commitInitialMutation = () => {
              try {
                commitHistorySnapshotNow({ force: true, reason: `update-object:${objectId}:initial` });
              } catch {}
            };
            if (typeof queueMicrotask === 'function') queueMicrotask(commitInitialMutation);
            else void Promise.resolve().then(commitInitialMutation);
          } else {
            scheduleHistorySnapshot({ force: true, reason: `update-object:${objectId}` });
          }
        } catch {}
      }
      return obj;
    },

    getObjectById(objectId: string) {
      if (!objectId) return null as any;
      const record = findDocumentObjectRecord(objectId);
      if (!record) return null as any;
      return getRuntimeObjectById(objectId) ?? cloneJson(record.object);
    },

    setObjectVisible(objectId: string, visible: boolean) {
      if (isDesignerPermissionDenied('canHideLayers', { action: 'designer.setObjectVisible', objectId, visible })) return;
      const obj: any = (instance as any).updateObject?.(objectId, { visible: visible !== false, __skipPermissionCheck: true });
      if (!obj) return;
      obj.dirty = true;
    },

    toggleObjectVisible(objectId: string) {
      const record = findDocumentObjectRecord(objectId);
      if (!record) return;
      (instance as any).setObjectVisible(objectId, record.object?.visible === false);
    },

    setObjectLocked(objectId: string, locked: boolean) {
      if (isDesignerPermissionDenied('canLockLayers', { action: 'designer.setObjectLocked', objectId, locked })) return;
      const v = !!locked;
      const current: any = getRuntimeObjectById(objectId) ?? findDocumentObjectRecord(objectId)?.object ?? null;
      if (!current) return;

      const state: any = useAsukaStore.getState?.() ?? {};
      const effective = getEffectivePermissions(
        (state.permissions ?? DEFAULT_PERMISSIONS) as AsukaPermissions,
        (state.permissionsByView?.[state.activeViewId ?? ''] ?? null) as any,
      );
      const canSelect = Boolean(
        effective.canMoveObject
        || effective.canResizeObject
        || effective.canRotateObject
        || effective.canDeleteObject
        || effective.canDuplicateObject
        || effective.canEditObjectColors
        || effective.canEditObjectText
        || effective.canEditObjectStudio,
      );
      const savedRestoreState = getFabricLayerLockRestoreState(current);
      const fallbackUnlocked = isFabricElementObject(current)
        ? {
            selectable: current.selectable === true,
            evented: true,
            hasControls: current.selectable === true,
            hasBorders: current.selectable === true,
            hoverCursor: current.selectable === true ? 'move' : 'pointer',
            perPixelTargetFind: true,
            lockMovementX: effective.canMoveObject === false,
            lockMovementY: effective.canMoveObject === false,
            lockScalingX: effective.canResizeObject === false,
            lockScalingY: effective.canResizeObject === false,
            lockRotation: effective.canRotateObject === false,
          }
        : {
            selectable: canSelect,
            evented: true,
            hasControls: Boolean(effective.canResizeObject || effective.canRotateObject),
            hasBorders: canSelect,
            hoverCursor: canSelect ? 'move' : 'default',
            perPixelTargetFind: current.perPixelTargetFind === true,
            lockMovementX: effective.canMoveObject === false,
            lockMovementY: effective.canMoveObject === false,
            lockScalingX: effective.canResizeObject === false,
          lockScalingY: effective.canResizeObject === false,
          lockRotation: effective.canRotateObject === false,
        };
      const unlockedState = resolveFabricLayerUnlockState(current, fallbackUnlocked);
      const obj: any = (instance as any).updateObject?.(objectId, {
        ...(v ? {
          selectable: false,
          evented: false,
          hasControls: false,
          hasBorders: false,
          hoverCursor: 'default',
          perPixelTargetFind: false,
          lockMovementX: true,
          lockMovementY: true,
          lockScalingX: true,
          lockScalingY: true,
          lockRotation: true,
          [FABRIC_LAYER_LOCK_RESTORE_PROPERTY]: savedRestoreState ?? captureFabricLayerInteractionState(current),
        } : {
          ...unlockedState,
          [FABRIC_LAYER_LOCK_RESTORE_PROPERTY]: null,
        }),
        [FABRIC_LAYER_LOCK_PROPERTY]: v,
        __skipPermissionCheck: true,
      });
      if (!obj) return;
      obj.dirty = true;
      try {
        const activeObjects = canvas.getActiveObjects?.() ?? [];
        if (v && (canvas.getActiveObject?.() === obj || activeObjects.includes(obj))) canvas.discardActiveObject?.();
      } catch {}
      try { canvas.requestRenderAll?.(); } catch {}
    },

    toggleObjectLocked(objectId: string) {
      const record = findDocumentObjectRecord(objectId);
      if (!record) return;
      const runtime: any = getRuntimeObjectById(objectId) ?? record.object;
      const locked = runtime?.[FABRIC_LAYER_LOCK_PROPERTY] === true
        || (runtime?.selectable === false && runtime?.evented === false && runtime?.lockMovementX === true && runtime?.lockMovementY === true);
      (instance as any).setObjectLocked(objectId, !locked);
    },

    removeObject(objectId: string) {
      if (isDesignerPermissionDenied('canDeleteObject', { action: 'designer.removeObject', objectId })) return;
      if (!objectId) return;
      const state = useAsukaStore.getState() as any;
      const viewId = String(state?.activeViewId ?? '').trim();
      if (!viewId) return;

      try { flushPendingHistorySnapshot({ force: true, reason: 'removeObject:pre-delete' }); } catch {}
      const current = Array.isArray(getDocumentObjectsState()[viewId]) ? getDocumentObjectsState()[viewId] : [];
      const removed = current.find((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '') === String(objectId)) as any;
      if (!removed) {

        // Fallback for rare desyncs: remove the live object, resync the active view,
        // then commit the post-delete state immediately.
        try {
          const live = getRuntimeObjectById(objectId);
          if (live) {
            emitBefore('canvas.object.remove', { objectId, fallback: true }, { instanceId });
            try { canvas.discardActiveObject?.(); } catch {}
            try { canvas.remove?.(live); } catch {}
            try { disposeFabricObjectDeep(live); } catch {}
            try { syncDocumentObjectsForView(); } catch {}
            try { useAsukaStore.getState().setDocumentSelection?.({ activeObjectIds: [] } as any); } catch {}
            try { canvas.requestRenderAll?.(); } catch {}
            try { commitHistorySnapshotNow({ force: true, skipCanvasSync: true, reason: 'removeObject:fallback-post-delete' }); } catch {}
            emitAfter('canvas.object.remove', { objectId, fallback: true }, { instanceId });
          }
        } catch {}
        return;
      }

      emitBefore('canvas.object.remove', { objectId }, { instanceId });
      const nextObjects = current.filter((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '') !== String(objectId));

      setDocumentObjectsForViewState(viewId, nextObjects);
      try { useAsukaStore.getState().setDocumentSelection?.({ activeObjectIds: [] } as any); } catch {}
      try { commitHistorySnapshotNow({ force: true, skipCanvasSync: true, reason: 'removeObject:post-delete-state' }); } catch {}
      rebuildViewFromDocumentState(viewId);
      try {
        if (removed?._asukaFixedSlotId && removed?._asukaFixedSlotIsContent) {
          fixedSlotsRuntime?.ensureIndicatorsForSlots?.();
          useAsukaStore.getState().setFixedSlotActive?.(null);
        }
      } catch {}
      emitAfter('canvas.object.remove', { objectId }, { instanceId });
    },

    setCanvasFixedSize(width: number, height: number) {
      setCanvasFixedSizeImpl(width, height);
    },

    setCanvasFitToHost() {
      setCanvasFitToHostImpl();
    },

    resizeToHost() {
      fitViewportToHost();
    },

    refreshUiScaling() {
      try { syncVisualUiScale(); } catch {}
      try { canvas.requestRenderAll?.(); } catch {}
    },

    setDocumentSize(width: number, height: number) {
      const w = Math.max(1, Math.floor(Number(width) || 1));
      const h = Math.max(1, Math.floor(Number(height) || 1));
      docWidth = w;
      docHeight = h;
      fitViewportToHost();
    },


  // --- Viewport (zoom/pan) -------------------------------------------------

  getZoom() {
    try {
      return canvas.getZoom?.() ?? 1;
    } catch {
      return 1;
    }
  },

  setZoom(zoom: number, opts?: { center?: { x: number; y: number }; clamp?: boolean }) {
    if (isDesignerPermissionDenied('canUseZoom', { action: 'designer.setZoom', zoom, opts })) return;
    const currentZoom = Number(canvas.getZoom?.() ?? 1) || 1;
    if (Number(zoom) > currentZoom && isDesignerPermissionDenied('canZoomIn', { action: 'designer.setZoom', zoom, currentZoom })) return;
    if (Number(zoom) < currentZoom && isDesignerPermissionDenied('canZoomOut', { action: 'designer.setZoom', zoom, currentZoom })) return;
    const clamp = opts?.clamp !== false;
    const minZoom = 0.1;
    const maxZoom = 8;
    const next = clamp ? Math.min(maxZoom, Math.max(minZoom, Number(zoom) || 1)) : (Number(zoom) || 1);

    // Default center: canvas center (screen coords)
    const center = opts?.center ?? { x: canvas.getWidth?.() / 2, y: canvas.getHeight?.() / 2 };

    try {
      const pt = new fabric.Point(center.x, center.y);
      canvas.zoomToPoint(pt, next);
      try { syncVisualUiScale(); } catch {}
      canvas.requestRenderAll?.();
    } catch {
      try {
        canvas.setZoom?.(next);
        try { syncVisualUiScale(); } catch {}
        canvas.requestRenderAll?.();
      } catch {}
    }
    try { syncDocumentViewportState((useAsukaStore.getState() as any).activeViewId ?? null, canvas); } catch {}
    notifyChange();
  },

  zoomIn(step = 0.1) {
    const z = instance.getZoom();
    instance.setZoom(z * (1 + Math.max(0.01, step)));
  },

  zoomOut(step = 0.1) {
    const z = instance.getZoom();
    instance.setZoom(z / (1 + Math.max(0.01, step)));
  },

  resetZoom() {
    if (isDesignerPermissionDenied('canResetViewport', { action: 'designer.resetZoom' })) return;
    instance.setZoom(1);
  },

  resetView() {
    if (isDesignerPermissionDenied('canFitViewport', { action: 'designer.resetView' })) return;
    try {
      if (__homeViewportTransform) {
        canvas.setViewportTransform([...__homeViewportTransform]);
        try { syncVisualUiScale(); } catch {}
        canvas.requestRenderAll?.();
      } else {
        // Fallback to a safe reset.
        instance.resetZoom();
        instance.resetPan();
      }
    } catch {
      try {
        instance.resetZoom();
        instance.resetPan();
      } catch {}
    }
    try { syncDocumentViewportState((useAsukaStore.getState() as any).activeViewId ?? null, canvas); } catch {}
    notifyChange();
  },

  panBy(dx: number, dy: number) {
    if (isDesignerPermissionDenied('canPanCanvas', { action: 'designer.panBy', dx, dy })) return;
    try {
      const vpt = canvas.viewportTransform;
      if (!vpt) return;
      vpt[4] += Number(dx) || 0;
      vpt[5] += Number(dy) || 0;
      canvas.setViewportTransform(vpt);
      try { syncVisualUiScale(); } catch {}
      canvas.requestRenderAll?.();
      try { syncDocumentViewportState((useAsukaStore.getState() as any).activeViewId ?? null, canvas); } catch {}
      notifyChange();
    } catch {}
  },

  resetPan() {
    if (isDesignerPermissionDenied('canResetViewport', { action: 'designer.resetPan' })) return;
    try {
      const vpt = canvas.viewportTransform;
      if (!vpt) return;
      vpt[4] = 0;
      vpt[5] = 0;
      canvas.setViewportTransform(vpt);
      try { syncVisualUiScale(); } catch {}
      canvas.requestRenderAll?.();
      try { syncDocumentViewportState((useAsukaStore.getState() as any).activeViewId ?? null, canvas); } catch {}
      notifyChange();
    } catch {}
  },

  fitToViewport(padding = 40) {
    if (isDesignerPermissionDenied('canFitViewport', { action: 'designer.fitToViewport', padding })) return;
    // Fit all objects in viewport.
    try {
      const objs = canvas.getObjects?.() ?? [];
      if (ASUKA_CONTROLS_DEBUG) void 0;
      if (!objs.length) {
        instance.resetZoom();
        instance.resetPan();
        return;
      }
      const group = new fabric.Group(objs.map((o: any) => o), { selectable: false, evented: false } as any);
      const bounds = group.getBoundingRect(true, true);

      const cw = canvas.getWidth?.() ?? 800;
      const ch = canvas.getHeight?.() ?? 600;
      const pw = Math.max(1, cw - 2 * padding);
      const ph = Math.max(1, ch - 2 * padding);

      const zx = pw / Math.max(1, bounds.width);
      const zy = ph / Math.max(1, bounds.height);
      const nextZoom = Math.min(zx, zy);

      // Zoom around center of bounds mapped to screen center
      instance.setZoom(nextZoom, { center: { x: cw / 2, y: ch / 2 } });

      // Center: move bounds center to screen center
      const vpt = canvas.viewportTransform;
      if (vpt) {
        const z = canvas.getZoom?.() ?? nextZoom;
        const boundsCenterX = (bounds.left + bounds.width / 2) * z;
        const boundsCenterY = (bounds.top + bounds.height / 2) * z;
        vpt[4] = cw / 2 - boundsCenterX;
        vpt[5] = ch / 2 - boundsCenterY;
        canvas.setViewportTransform(vpt);
        canvas.requestRenderAll?.();
      }
      notifyChange();
    } catch {
      // ignore
    }
  },

    /**
     * Internal helper exposed for store/actions.
     * Applies a FixedSlotConstraints object to a Fabric object (locks, min/max, colors, fonts...).
     */
    applyFixedConstraintsToObject(obj: any, constraints?: any) {
      return applyFixedConstraintsToObject(obj, constraints);
    },

    /**
     * Internal helper exposed for marker edits.
     * Applies the configured replacement width/height for a fixed slot to a Fabric object.
     */
    applyFixedSlotTargetSizeToObject(obj: any, slot?: any) {
      return applyFixedSlotTargetSizeToObject(obj, slot);
    },

    /**
     * Set a logical group tag on an object (used by modules like Colors/Layers).
     * Does not re-render by itself.
     */
    setItemGroup(object: any, itemGroup: 'Designs' | 'Images' | 'Text' | 'Tools' | 'Other') {
      if (!object) return;
      (object as any).itemGroup = itemGroup;
    },

    /**
     * Returns objects matching an itemGroup.
     * - scope='object'   -> just checks the provided object
     * - scope='view'      -> current view canvas
     * - scope='document'  -> all canvases/views
     */
    getObjectsByItemGroup(
      itemGroup: 'Designs' | 'Images' | 'Text' | 'Tools' | 'Other',
      scope?: 'view' | 'document'
    ) {
      const s = scope ?? 'view';
      const canvases = s === 'document' && instance.getCanvases ? instance.getCanvases('toArray') : [canvas];
      const out: any[] = [];
      const seen = new Set<any>();

      for (const c of canvases) {
        if (!c) continue;
        for (const o of c.getObjects?.() ?? []) {
          if (seen.has(o)) continue;
          seen.add(o);
          if ((o as any).itemGroup === itemGroup) out.push(o);
        }
      }
      return out;
    },

    async addImageFromUrl(url: string, opts: any = {}) {
      const group = String(opts?.itemGroup ?? 'Images').toLowerCase();
      const key: keyof AsukaPermissions = group === 'designs' || group === 'design' ? 'canAddDesigns' : 'canAddImage';
      if (isDesignerPermissionDenied(key, { action: 'designer.addImageFromUrl', url, opts })) return;
      if (/^https?:\/\//i.test(String(url ?? '')) && opts?.__skipRemoteImagePermissionCheck !== true && isDesignerPermissionDenied('canUseRemoteImages', { action: 'designer.addImageFromUrl', url, opts })) return;
      const activeSlot = consumeActiveFixedSlot();
      if (activeSlot && activeSlot.type === 'image') {
        opts = {
          ...(opts || {}),
          left: activeSlot.x,
          top: activeSlot.y,
          center: true,
        };
      }
      // Lazy import to avoid circular deps
      await ensureZoneForAdd();
      const beforeObjectIds = activeSlot && activeSlot.type === 'image' ? captureCanvasObjectIds() : null;
      await addCoreImageFromUrl(instance as any, url, opts);

      if (activeSlot && activeSlot.type === 'image') {
        const added: any = resolveAddedCanvasObject(beforeObjectIds, activeSlot);
        if (added) {
          finalizeFixedSlotReplacement(added, activeSlot);
        }
      }
      try { commitHistoryChange({ force: true }); } catch {}
    },

    async addImageFromFile(file: File, opts: any = {}) {
      if (isDesignerPermissionDenied('canUploadImage', { action: 'designer.addImageFromFile', fileName: (file as any)?.name })) return;
      if (isDesignerPermissionDenied('canAddImage', { action: 'designer.addImageFromFile', fileName: (file as any)?.name })) return;
      const activeSlot = consumeActiveFixedSlot();
      if (activeSlot && activeSlot.type === 'image') {
        opts = {
          ...(opts || {}),
          left: activeSlot.x,
          top: activeSlot.y,
          center: true,
        };
      }
      await ensureZoneForAdd();
      const beforeObjectIds = activeSlot && activeSlot.type === 'image' ? captureCanvasObjectIds() : null;
      await addCoreImageFromFile(instance as any, file, { ...(opts || {}), __skipRemoteImagePermissionCheck: true });

      if (activeSlot && activeSlot.type === 'image') {
        const added: any = resolveAddedCanvasObject(beforeObjectIds, activeSlot);
        if (added) {
          finalizeFixedSlotReplacement(added, activeSlot);
        }
      }
      try { commitHistoryChange({ force: true }); } catch {}
    },

    async addSvgFromUrl(url: string, opts: any = {}) {
      const group = String(opts?.itemGroup ?? 'Images').toLowerCase();
      const key: keyof AsukaPermissions = group === 'designs' || group === 'design' ? 'canAddDesigns' : 'canAddImage';
      if (isDesignerPermissionDenied(key, { action: 'designer.addSvgFromUrl', url, opts })) return;
      if (/^https?:\/\//i.test(String(url ?? '')) && opts?.__skipRemoteImagePermissionCheck !== true && isDesignerPermissionDenied('canUseRemoteImages', { action: 'designer.addSvgFromUrl', url, opts })) return;
      const activeSlot = consumeActiveFixedSlot();
      if (activeSlot && activeSlot.type === 'design') {
        opts = {
          ...(opts || {}),
          left: activeSlot.x,
          top: activeSlot.y,
          center: true,
        };
      }
      await ensureZoneForAdd();
      const beforeObjectIds = activeSlot && activeSlot.type === 'design' ? captureCanvasObjectIds() : null;
      await addCoreSvgFromUrl(instance as any, url, opts);

      if (activeSlot && activeSlot.type === 'design') {
        const added: any = resolveAddedCanvasObject(beforeObjectIds, activeSlot);
        if (added) {
          finalizeFixedSlotReplacement(added, activeSlot);
        }
      }
      try { commitHistoryChange({ force: true }); } catch {}
    },
async addSvgFromString(svgText: string, opts: any = {}) {
  const group = String(opts?.itemGroup ?? 'Images').toLowerCase();
  const key: keyof AsukaPermissions = group === 'designs' || group === 'design' ? 'canAddDesigns' : group === 'other' ? 'canAddShape' : 'canAddImage';
  if (isDesignerPermissionDenied(key, { action: 'designer.addSvgFromString', opts })) return;
  const activeSlot = consumeActiveFixedSlot();
  if (activeSlot && activeSlot.type === 'design') {
    opts = { ...(opts || {}), left: activeSlot.x, top: activeSlot.y, center: true };
  }
  await ensureZoneForAdd();
  const beforeObjectIds = activeSlot && activeSlot.type === 'design' ? captureCanvasObjectIds() : null;
      await addCoreSvgFromString(instance as any, svgText, opts);

  if (activeSlot && activeSlot.type === 'design') {
    const added: any = resolveAddedCanvasObject(beforeObjectIds, activeSlot);
    if (added) {
      finalizeFixedSlotReplacement(added, activeSlot);
    }
  }
  try { commitHistoryChange({ force: true }); } catch {}
},

async curveActiveText(opts: { type?: string; bend?: number; radius?: number; text?: string } = {}) {
  if (isDesignerPermissionDenied('canEditText', { action: 'designer.curveActiveText' })) return;
  if (isDesignerPermissionDenied('canEditTextCurve', { action: 'designer.curveActiveText' })) return;
  if (isDesignerPermissionDenied('canEditObjectText', { action: 'designer.curveActiveText' })) return;
  const active = canvas.getActiveObject();
  if (!active) return;

  if (isCurvedPathTextObject(active)) {
    updateCurvedPathObject(active, opts as any);
    return;
  }

  const isTextbox =
    active?.type === 'textbox' ||
    active?.constructor?.name === 'Textbox' ||
    (active && typeof (active as any).text === 'string' && typeof (active as any).fontSize === 'number');

  if (!isTextbox) return;

  const curved = convertTextboxToCurvedPathText(active as any, opts as any);
  // Ensure selection updates immediately and external listeners refresh
  try { canvas.setActiveObject?.(curved); } catch {}
  try { canvas.fire?.('selection:updated', { selected: [curved], target: curved }); } catch {}
  try { canvas.requestRenderAll?.(); } catch { try { canvas.renderAll?.(); } catch {} }
},

updateCurvedText(opts: any) {
  if (isDesignerPermissionDenied('canEditText', { action: 'designer.updateCurvedText' })) return;
  if (isDesignerPermissionDenied('canEditTextCurve', { action: 'designer.updateCurvedText' })) return;
  if (isDesignerPermissionDenied('canEditObjectText', { action: 'designer.updateCurvedText' })) return;
  const active: any = canvas.getActiveObject();
  if (!active) return;

  const isCommit = !!opts?.__commit;
  const isPreview = !!opts?.__preview;

  if (isCurvedPathTextObject(active)) {
    const inner = getCurvedPathInnerText(active);
    if (opts?.text != null && inner) {
      inner.set?.({ text: String(opts.text) });
      (inner as any)._asukaCurvedTextData = { ...(inner as any)._asukaCurvedTextData, text: String(opts.text) };
    }
    // Strip internal flags from curve-data patch
    const patch = { ...(opts || {}) };
    delete (patch as any).__commit;
    delete (patch as any).__preview;
    updateCurvedPathObject(active, patch as any);

    // Refresh the helper guideline (dashed path) immediately when curve params
    // change (type/bend/radius/text). Relying on selection events is not
    // enough because programmatic updates don't always trigger them.
    try {
      canvas.fire?.('selection:updated', { selected: [active], target: active });
    } catch {}

    // Keep zone feedback (selection border color) in sync since the bounds can
    // change when the curve type changes.
    try {
      const zone = getZoneForObject(active);
      const status = classifyObjectPosition(active, zone);
      updateObjectStrokeForZone(active, status);
      rememberLastGoodPosition(active, status);
    } catch {}

    // During preview we render, but we do not add a history step.
    if (isCommit) {
      try { saveSnapshot(); } catch {}
      try { emitAsukaEvent?.('curvedText:updated', { objectId: ensureObjectId(active) }); } catch {}
    }
    if (!isPreview) {
      try { canvas.requestRenderAll?.(); } catch { try { canvas.renderAll?.(); } catch {} }
    }
    return;
  }

  if (isCurvedTextObject(active)) {
    if (opts?.text != null) patchCurvedTextLettersText(active, String(opts.text));
    updateCurvedGroup(active, opts as any);
    return;
  }
},

uncurveActiveText() {
  if (isDesignerPermissionDenied('canEditText', { action: 'designer.uncurveActiveText' })) return;
  if (isDesignerPermissionDenied('canEditTextCurve', { action: 'designer.uncurveActiveText' })) return;
  if (isDesignerPermissionDenied('canEditObjectText', { action: 'designer.uncurveActiveText' })) return;
  const active: any = canvas.getActiveObject();
  if (!active || (!isCurvedTextObject(active) && !isCurvedPathTextObject(active))) return;

  if (isCurvedPathTextObject(active)) {
    const inner = getCurvedPathInnerText(active);
    const data = (inner as any)?._asukaCurvedTextData || (active as any)._asukaCurvedTextData || {};
    const source = {
      ...(data.source || {}),
      ...extractTextboxStyle(inner),
    };

    const center = typeof active.getCenterPoint === 'function' ? active.getCenterPoint() : { x: active.left, y: active.top };

    const tbWidth = Number(data.textboxWidth);
    const baseW = Number(data.baseW);

    const tb = new fabric.Textbox(String(data.text ?? inner?.text ?? ''), {
      ...(source || {}),
      left: center.x,
      top: center.y,
      originX: 'center',
      originY: 'center',
      angle: active.angle,
      scaleX: active.scaleX,
      scaleY: active.scaleY,
      width:
        Number.isFinite(tbWidth) && tbWidth > 10
          ? tbWidth
          : Number.isFinite(baseW) && baseW > 10
            ? baseW + 40
            : undefined,
    });

    if ((active as any)._asukaObjectId) (tb as any)._asukaObjectId = (active as any)._asukaObjectId;
    if ((active as any)._asukaId) (tb as any)._asukaId = (active as any)._asukaId;
    ensureObjectId(tb);
    (tb as any)._asukaZoneId = (active as any)._asukaZoneId ?? getZone().id;
    applyClipForObject(tb);
    asukaApplyControls(tb);

    if ((active as any).price != null) (tb as any).price = (active as any).price;
    if ((active as any)._asukaPrice != null) (tb as any)._asukaPrice = (active as any)._asukaPrice;

    replaceInCanvas(active, tb);
    // Keep zone feedback (selection border color) consistent when converting back.
    try {
      const zone = getZoneForObject(tb);
      const status = classifyObjectPosition(tb, zone);
      updateObjectStrokeForZone(tb, status);
      rememberLastGoodPosition(tb, status);
    } catch {}
    try { canvas.fire?.('selection:updated', { selected: [tb], target: tb }); } catch {}
    try { canvas.requestRenderAll?.(); } catch { try { canvas.renderAll?.(); } catch {} }
    return;
  }

  const data = (active as any)._asukaCurvedTextData || {};
  const source = data.source || {};
  const center = typeof active.getCenterPoint === 'function' ? active.getCenterPoint() : { x: active.left, y: active.top };

  const tb = new fabric.Textbox(String(data.text ?? ''), {
    ...(source || {}),
    left: center.x,
    top: center.y,
    originX: 'center',
    originY: 'center',
    angle: active.angle,
    scaleX: active.scaleX,
    scaleY: active.scaleY,
  });

  if ((active as any)._asukaObjectId) (tb as any)._asukaObjectId = (active as any)._asukaObjectId;
  if ((active as any)._asukaId) (tb as any)._asukaId = (active as any)._asukaId;
  ensureObjectId(tb);
  (tb as any)._asukaZoneId = (active as any)._asukaZoneId ?? getZone().id;
  applyClipForObject(tb);
  asukaApplyControls(tb);

  if ((active as any).price != null) (tb as any).price = (active as any).price;
  if ((active as any)._asukaPrice != null) (tb as any)._asukaPrice = (active as any)._asukaPrice;

  replaceInCanvas(active, tb);
  try {
    const zone = getZoneForObject(tb);
    const status = classifyObjectPosition(tb, zone);
    updateObjectStrokeForZone(tb, status);
    rememberLastGoodPosition(tb, status);
  } catch {}
  try { canvas.fire?.('selection:updated', { selected: [tb], target: tb }); } catch {}
  try { canvas.requestRenderAll?.(); } catch { try { canvas.renderAll?.(); } catch {} }
},

    setSnapping(patch) {
      if (!patch || typeof patch !== 'object') return;
      if (typeof patch.enabled === 'boolean') snapConfig.enabled = patch.enabled;
      if (typeof patch.threshold === 'number' && !Number.isNaN(patch.threshold)) {
        snapConfig.threshold = Math.max(0, patch.threshold);
        if (typeof patch.release !== 'number') {
          snapConfig.release = snapConfig.threshold + 12;
        }
      }
      if (typeof patch.release === 'number' && !Number.isNaN(patch.release)) {
        snapConfig.release = Math.max(0, patch.release);
      }
      if (patch.priority === 'zones-first' || patch.priority === 'objects-first') {
        snapConfig.priority = patch.priority;
      }
    },

    getSnapping() {
      return { ...snapConfig };
    },


addShape(options: DesignerShapeOptions) {
  if (!options || isDesignerPermissionDenied('canAddShape', { action: 'designer.addShape', kind: options?.kind })) return null;
  const kind = options.kind;
  const width = Math.max(1, Number(options.width ?? (kind === 'circle' ? 140 : 160)) || 1);
  const height = Math.max(1, Number(options.height ?? (kind === 'rect' ? 110 : kind === 'line' ? 1 : 140)) || 1);
  const fill = typeof options.fill === 'string' ? options.fill : 'transparent';
  const stroke = typeof options.stroke === 'string' ? options.stroke : '#111827';
  const strokeWidth = Math.max(0, Number(options.strokeWidth ?? 2) || 0);
  const opacity = Math.min(1, Math.max(0, Number(options.opacity ?? 1) || 0));
  const canvasWidth = Math.max(1, Number(canvas.getWidth?.() ?? 800) || 800);
  const canvasHeight = Math.max(1, Number(canvas.getHeight?.() ?? 600) || 600);
  let center = { x: canvasWidth / 2, y: canvasHeight / 2 };
  try {
    const screenCenter = new fabric.Point(center.x, center.y);
    const inverse = fabric.util.invertTransform(canvas.viewportTransform);
    const worldCenter = fabric.util.transformPoint(screenCenter, inverse);
    center = { x: worldCenter.x, y: worldCenter.y };
  } catch {}

  let object: any = null;
  if (kind === 'rect') {
    const radius = Math.max(0, Number(options.rectRadius ?? 0) || 0);
    object = new fabric.Rect({ left: center.x, top: center.y, width, height, rx: radius, ry: radius, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
    object._asukaRectRadius = radius;
  } else if (kind === 'circle') {
    object = new fabric.Ellipse({ left: center.x, top: center.y, rx: width / 2, ry: height / 2, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
  } else if (kind === 'triangle') {
    object = new fabric.Triangle({ left: center.x, top: center.y, width, height, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
  } else if (kind === 'line') {
    const lineWidth = strokeWidth > 0 ? strokeWidth : 3;
    object = new fabric.Line([-width / 2, 0, width / 2, 0], { left: center.x, top: center.y, stroke, strokeWidth: lineWidth, opacity, originX: 'center', originY: 'center' });
  } else if (kind === 'star') {
    const spikes = Math.max(3, Math.min(32, Number(options.starSpikes ?? 5) || 5));
    const outerRadius = Math.min(width, height) / 2;
    const innerRatio = Math.min(0.95, Math.max(0.05, Number(options.starInnerRatio ?? 0.45) || 0.45));
    const points: Array<{ x: number; y: number }> = [];
    let angle = (Math.PI / 2) * 3;
    const step = Math.PI / spikes;
    for (let index = 0; index < spikes; index += 1) {
      points.push({ x: Math.cos(angle) * outerRadius, y: Math.sin(angle) * outerRadius });
      angle += step;
      points.push({ x: Math.cos(angle) * outerRadius * innerRatio, y: Math.sin(angle) * outerRadius * innerRatio });
      angle += step;
    }
    object = new fabric.Polygon(points, { left: center.x, top: center.y, fill, stroke, strokeWidth, opacity, originX: 'center', originY: 'center' });
    object._asukaStarSpikes = spikes;
    object._asukaStarInnerRatio = innerRatio;
  }
  if (!object) return null;
  object._asukaShapeKind = kind;
  object.itemGroup = 'Other';
  if (options.printAreaId) object._asukaZoneId = String(options.printAreaId);
  if (options.metadata && typeof options.metadata === 'object') object.metadata = { ...options.metadata };
  return (instance as any).addObject?.(object, { setActive: true, commitHistory: true, itemGroup: 'Other' }) ?? null;
},

addRect(options?: any) {
  if (isDesignerPermissionDenied('canAddRect', { action: 'designer.addRect', options })) return null;
  emitBefore(
    'canvas.object.add',
    { objectType: 'rect', options: options ?? null },
    { instanceId },
  );

  const rect = new fabric.Rect({
    ...defaultRectOptions,
    ...(options || {}),
  });

  ensureObjectId(rect);
  asukaEnsureCenterTransformOrigin(rect);
  try { ensureObjectReadableOnInsert(rect, canvas, options); } catch {}
  { const normalizedPrice = normalizePriceInput(options?.price); if (typeof normalizedPrice === 'number') (rect as any).price = normalizedPrice; }
  (rect as any)._asukaZoneId = getZone().id;
  applyClipForObject(rect);

  (instance as any).addObject?.(rect, { setActive: true, commitHistory: true, __skipPermissionCheck: true });

  emitAfter(
    'canvas.object.add',
    { objectType: 'rect', objectId: ensureObjectId(rect) },
    { instanceId },
  );

  return rect;
},

addText(text: string, options?: any) {
  if (isDesignerPermissionDenied('canAddText', { action: 'designer.addText', text, options })) return null;
  // Fixed slot replacement
  const activeSlot = consumeActiveFixedSlot();
  const slotConstraints = activeSlot?.constraints as any;
  if (activeSlot && activeSlot.type === 'text') {
    options = {
      ...(options || {}),
      left: activeSlot.x,
      top: activeSlot.y,
      originX: 'center',
      originY: 'center',
    };
  }

  emitBefore(
    'canvas.object.add',
    { objectType: 'text', text, options: options ?? null },
    { instanceId },
  );

  const textbox = new fabric.Textbox(text, {
    left: logicalWidth / 2,
    top: logicalHeight / 2,
    originX: 'center',
    originY: 'center',
    width: 160,
    textAlign: 'left',
    centeredRotation: true,
    fontSize: 26,
    fill: '#111827',
    ...(options || {}),
  });

  ensureObjectId(textbox);
  asukaEnsureCenterTransformOrigin(textbox);
  try {
    applyFontMetadataToObject(textbox);
    autoSizeTextboxToContent(textbox);
    ensureObjectReadableOnInsert(textbox, canvas, options);
    autoSizeTextboxToContent(textbox);
  } catch {}
  if (activeSlot && activeSlot.type === 'text') {
    attachObjectToFixedSlot(textbox, activeSlot);
  } else {
    applyFixedConstraintsToObject(textbox, slotConstraints);
  }
  // Used for filtering/UX (layers, colors scope, etc.)
  { const normalizedPrice = normalizePriceInput(options?.price); if (typeof normalizedPrice === 'number') (textbox as any).price = normalizedPrice; }
  (textbox as any).itemGroup = (textbox as any).itemGroup ?? 'Text';
  (textbox as any)._asukaZoneId = getZone().id;

  applyClipForObject(textbox);

  const runtimeTextbox = (instance as any).addObject?.(textbox, { setActive: true, commitHistory: false, prepareObject: true, __skipPermissionCheck: true });

  // Fabric textboxes can restore default middle handles after construction.
  // Force-hide it again on the final runtime object.
  try {
    const target = runtimeTextbox ?? textbox;
    target?.setControlsVisibility?.({ mt: false, mb: false, ml: false, mr: false, mtr: false });
    target?.setControlVisible?.('mt', false);
    target?.setControlVisible?.('mb', false);
    target?.setControlVisible?.('ml', false);
    target?.setControlVisible?.('mr', false);
    target?.setControlVisible?.('mtr', false);
    if (target?.controls) {
      delete (target.controls as any).mt;
      delete (target.controls as any).mb;
      delete (target.controls as any).ml;
      delete (target.controls as any).mr;
      delete (target.controls as any).mtr;
    }
  } catch {}

  if (activeSlot && activeSlot.type === 'text') {
    finalizeFixedSlotReplacement(runtimeTextbox ?? textbox, activeSlot);
  }

  try { scheduleHistorySnapshot({ force: true }); } catch {}

  emitAfter(
    'canvas.object.add',
    { objectType: 'text', objectId: ensureObjectId(textbox) },
    { instanceId },
  );

  return textbox;
},

// ---------------------------------------------------------------------------
// Elements (catalog + canvas instances)
// ---------------------------------------------------------------------------

getElements() {
  return (useAsukaStore.getState() as any).elements ?? [];
},

addElement(identifier: string, element: any) {
  if (isDesignerPermissionDenied('canAddElements', { action: 'designer.addElement', identifier, element })) return;
  try { (useAsukaStore.getState() as any).addElement?.(String((element as any)?.id ?? identifier ?? ''), element); } catch {}
},

editElement(identifier: string, patch: any) {
  if (isDesignerPermissionDenied('canEditElements', { action: 'designer.editElement', identifier, patch })) return;
  try { (useAsukaStore.getState() as any).editElement?.(identifier, patch); } catch {}
},

removeElement(identifier: string) {
  if (isDesignerPermissionDenied('canDeleteElements', { action: 'designer.removeElement', identifier })) return;
  try { (useAsukaStore.getState() as any).removeElement?.(identifier); } catch {}
},

async addElementInstance(identifier: string, opts?: { variationId?: string; at?: { x: number; y: number }; props?: any; fitMode?: 'contain' | 'native' }) {
  if (isDesignerPermissionDenied('canViewElements', { action: 'designer.addElementInstance', identifier, opts })) return;
  const store = useAsukaStore.getState() as any;
  const el = resolveElementRecord(store, String(identifier ?? ''));
  if (!el) {
    void 0;
    return;
  }
  const vars = Array.isArray(el.variations) ? el.variations : [];
  const findVariationDeepLocal = (variations: any[], vid: string): any | null => findVariationDeepById(variations, vid);
  const findFirstActiveDeep = (variations: any[]): any | null => {
    for (const v of variations ?? []) {
      if (!v) continue;
      if (v?.active) return v;
      const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
      const found = kids.length ? findFirstActiveDeep(kids) : null;
      if (found) return found;
    }
    return null;
  };
  const active = opts?.variationId
    ? findVariationDeepLocal(vars, String(opts?.variationId))
    : (findFirstActiveDeep(vars) ?? vars[0]);

  const at = opts?.at ?? { x: logicalWidth / 2, y: logicalHeight / 2 };

  const createFromUrl = async (url: string): Promise<any> => __createFabricObjectFromUrl(url);

  const createPlaceholder = () => {
    const rect = new fabric.Rect({ left: at.x - 60, top: at.y - 30, width: 120, height: 60, rx: 10, ry: 10, fill: '#e2e8f0', stroke: '#94a3b8', strokeWidth: 2 });
    const txt = new fabric.Text(String(active?.name ?? el.name), { left: at.x, top: at.y, originX: 'center', originY: 'center', fontSize: 14, fill: '#0f172a' });
    const group = new (fabric as any).Group([rect, txt], { left: at.x, top: at.y, originX: 'center', originY: 'center' });
    return group;
  };

  let obj: any = null;
  try {
    const src = String(active?.image || el?.image || '').trim();
    if (src) {

      obj = await createFromUrl(src);
      obj.set?.({ left: at.x, top: at.y, originX: 'center', originY: 'center' });
    } else {
      void 0;
      obj = createPlaceholder();
    }
  } catch {
    void 0;
    obj = createPlaceholder();
  }

  if (!obj) {
    void 0;
    obj = createPlaceholder();
  }

  if (obj && typeof (obj as any).render !== 'function') {
    void 0;
    obj = createPlaceholder();
  }

  ensureObjectId(obj);
  asukaEnsureCenterTransformOrigin(obj);

  // Optional: apply placement props (copied from another view instance).
  // We only accept a safe subset of properties to avoid corrupting internal metadata.
  try {
    const p = opts?.props;
    if (p && typeof p === 'object') {
      const allowed: Record<string, any> = {};
      const keys = ['left', 'top', 'scaleX', 'scaleY', 'angle', 'opacity', 'flipX', 'flipY', 'skewX', 'skewY', 'originX', 'originY'];
      for (const k of keys) {
        if (k in (p as any)) (allowed as any)[k] = (p as any)[k];
      }
      if (Object.keys(allowed).length) {
        (obj as any).set?.(allowed);
      }
    }
  } catch {}

  // Element instances follow the element definition interactivity.
  // selectable controls layer selection, evented controls hit-testing / click interception.
  try {
    const selectable = Boolean((el as any)?.selectable === true);
    const evented = Boolean((el as any)?.evented !== false);
    obj.selectable = selectable;
    obj.evented = evented;
    (obj as any).hasControls = selectable;
    (obj as any).hasBorders = selectable;
    (obj as any).hoverCursor = evented ? (selectable ? 'move' : 'pointer') : 'default';
  } catch {}

  // Tag metadata BEFORE canvas.add so ordering rules see the correct family.
  (obj as any)._asukaElementIsInstance = true;
  (obj as any)._asukaElementId = String((el as any).id ?? makeElementId());
  (obj as any)._asukaElementFamilyId = String((el as any).familyId ?? (el as any).id ?? makeElementFamilyId());
  (obj as any)._asukaElementName = String(el.name);
  (obj as any)._asukaElementVariationId = String(active?.id ?? '');
  configureFabricElementInteraction(obj, {
    selectable: Boolean((el as any)?.selectable === true),
    evented: Boolean((el as any)?.evented !== false),
  });
  const elementPrice = getElementPriceBreakdown(el, active);
  (obj as any)._asukaElementBasePrice = elementPrice.base;
  (obj as any)._asukaElementVariationPrice = elementPrice.variation;
  (obj as any).price = elementPrice.total;
  (obj as any)._asukaBringToFront = Boolean(el.bringToFront);
  (obj as any).bringToFront = Boolean(el.bringToFront);
  (obj as any).itemGroup = (obj as any).itemGroup ?? 'Other';
  // Drawing zones: element instances are STRUCTURE objects.
  // They must NEVER be constrained/assigned to drawing areas.
  (obj as any)._asukaZoneId = undefined;
  (obj as any)._asukaIgnoreDrawingZones = true;

  applyClipForObject(obj);
  // IMPORTANT: do not attach transform controls to element instances.
  canvas.add(obj);

  // Elements are structural objects: when the asset is too large, scale it down.
  // - Default behavior: center + fit (nice UX on first placement).
  // - When placement props are provided (copy from another view): do NOT recenter.
  try {
    const w = Number(canvas.getWidth?.() ?? (canvas as any).width ?? 0) || 0;
    const h = Number(canvas.getHeight?.() ?? (canvas as any).height ?? 0) || 0;
    if (w > 0 && h > 0) {
      const preserveNativeSize = opts?.fitMode === 'native';
      const hasPlacementProps = !!(opts?.props && typeof opts.props === 'object');
      if (preserveNativeSize) {
        obj.set?.({ left: at.x, top: at.y, originX: 'center', originY: 'center' });
        obj.setCoords?.();
      } else if (!hasPlacementProps) {
        autoFitObjectToBounds(obj, { left: 0, top: 0, width: w, height: h }, 12);
      } else {
        // Scale-down only (preserve left/top)
        try { obj.setCoords?.(); } catch {}
        const br = obj.getBoundingRect?.(true, true);
        if (br && br.width > 0 && br.height > 0) {
          const maxW = Math.max(1, w - 24);
          const maxH = Math.max(1, h - 24);
          const factor = Math.min(1, maxW / br.width, maxH / br.height);
          if (factor < 1) {
            try {
              obj.scaleX = (obj.scaleX ?? 1) * factor;
              obj.scaleY = (obj.scaleY ?? 1) * factor;
            } catch {}
            try { obj.setCoords?.(); } catch {}
          }
        }
      }
    }
  } catch {}

  // All future bitmap variations must reuse this exact post-placement frame.
  // Capture it after auto-fit so a color swap cannot progressively move or
  // resize one piece relative to the other product layers.
  try { refreshFabricVariationFrame(obj); } catch {}

  if (el.bringToFront) {
    try { obj.bringToFront?.(); } catch {}
  }
  try { scheduleNormalizeLayers({ assignMissingOnly: false }); } catch {}
  // Elements are not selectable; keep canvas selection unchanged
  try { canvas.discardActiveObject?.(); } catch {}

  canvas.requestRenderAll();
  scheduleHistorySnapshot();
},


async syncElementInstancesToDocument(identifier: string, patch: { bringToFront?: boolean; selectable?: boolean; evented?: boolean }) {
  const elementId = String(identifier ?? '').trim();
  if (!elementId) return;
  const bring = typeof patch?.bringToFront === 'boolean' ? Boolean(patch.bringToFront) : undefined;
  const selectable = typeof patch?.selectable === 'boolean' ? Boolean(patch.selectable) : undefined;
  const evented = typeof patch?.evented === 'boolean' ? Boolean(patch.evented) : undefined;
  const resolveElementEvented = (_sel?: boolean, evt?: boolean) => Boolean(evt !== false);

  // Patch live canvas objects
  try {
    const objs = canvas.getObjects?.() ?? [];
    let touched = false;
    for (const obj of objs) {
      if (!obj) continue;
      if (String((obj as any)._asukaElementId ?? '').trim() !== elementId && String((obj as any)._asukaElementName ?? '').trim() !== elementId) continue;
      try {
        if (typeof bring === 'boolean') {
          (obj as any)._asukaBringToFront = bring;
          (obj as any).bringToFront = bring;
        }
        const nextSelectable = typeof selectable === 'boolean' ? selectable : Boolean((obj as any).selectable === true);
        const nextEvented = resolveElementEvented(nextSelectable, typeof evented === 'boolean' ? evented : (obj as any).evented);
        obj.selectable = nextSelectable;
        obj.evented = nextEvented;
        (obj as any).hasControls = nextSelectable;
        (obj as any).hasBorders = nextSelectable;
        (obj as any).hoverCursor = nextEvented ? (nextSelectable ? 'move' : 'pointer') : 'default';
        try { obj.setCoords?.(); } catch {}
        touched = true;
      } catch {}
    }
    if (touched) {
      try { normalizeLayersByRules({ assignMissingOnly: false, canvasRef: canvas }); } catch {}
      try { canvas.requestRenderAll?.(); } catch {}
    }
  } catch (_e) {
    void 0;
  }

  // Patch persisted document objects (all views)
  try {
    const st = useAsukaStore.getState() as any;
    const byView = (st?.documentObjectsByView && typeof st.documentObjectsByView === 'object') ? st.documentObjectsByView : {};
    const nextByView: Record<string, any[]> = {};
    let changed = false;
    for (const [viewId, list] of Object.entries(byView)) {
      const objects = Array.isArray(list) ? list : [];
      const next = objects.map((o: any) => {
        if (!o || (String(o._asukaElementId ?? '').trim() !== elementId && String(o._asukaElementName ?? '').trim() !== elementId)) return o;
        const copy = { ...(o ?? {}) } as any;
        if (typeof bring === 'boolean') {
          copy._asukaBringToFront = bring;
          copy.bringToFront = bring;
        }
        const nextSelectable = typeof selectable === 'boolean' ? selectable : Boolean(copy.selectable === true);
        const nextEvented = resolveElementEvented(nextSelectable, typeof evented === 'boolean' ? evented : copy.evented);
        copy.selectable = nextSelectable;
        copy.evented = nextEvented;
        copy.hasControls = nextSelectable;
        copy.hasBorders = nextSelectable;
        copy.hoverCursor = nextEvented ? (nextSelectable ? 'move' : 'pointer') : 'default';
        changed = true;
        return copy;
      });
      nextByView[String(viewId)] = next;
    }
    if (changed) {
      useAsukaStore.setState({ documentObjectsByView: nextByView } as any);
    }
  } catch (_e) {
    void 0;
  }

  // Always re-run ordering after flag changes so "toggle off" is reflected immediately.
  try { scheduleNormalizeLayers({ assignMissingOnly: false, canvasRef: canvas }); } catch {}
},


async syncElementEditToActiveView(identifier: string, element: any, options: { variationId?: string; objectIds?: string[]; source?: string } = {}) {
  const store = useAsukaStore.getState() as any;
  const activeViewId = String(store?.activeViewId ?? '').trim();
  if (!activeViewId) return;

  const normalizedElement = (element && typeof element === 'object') ? element : {};
  const identifiers = new Set<string>();
  const addIdentifier = (value: any) => {
    const normalized = String(value ?? '').trim();
    if (normalized) identifiers.add(normalized);
  };
  addIdentifier(identifier);
  addIdentifier((normalizedElement as any)?.id);
  addIdentifier((normalizedElement as any)?.name);
  addIdentifier((normalizedElement as any)?.familyId);

  const matchesElement = (obj: any): boolean => {
    if (!obj || !identifiers.size) return false;
    const elementId = String((obj as any)?._asukaElementId ?? '').trim();
    const elementName = String((obj as any)?._asukaElementName ?? '').trim();
    const familyId = String((obj as any)?._asukaElementFamilyId ?? '').trim();
    return Boolean((elementId && identifiers.has(elementId)) || (elementName && identifiers.has(elementName)) || (familyId && identifiers.has(familyId)));
  };

  const objectIds = new Set<string>();
  for (const id of Array.isArray(options?.objectIds) ? options.objectIds : []) {
    const normalized = String(id ?? '').trim();
    if (normalized) objectIds.add(normalized);
  }

  try {
    const byView = (store?.documentObjectsByView && typeof store.documentObjectsByView === 'object') ? store.documentObjectsByView : {};
    const activeObjects = Array.isArray(byView?.[activeViewId]) ? byView[activeViewId] : [];
    for (const obj of activeObjects) {
      if (!matchesElement(obj)) continue;
      const objectId = String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim();
      if (objectId) objectIds.add(objectId);
    }
  } catch {}

  try {
    const liveObjects = canvas.getObjects?.() ?? [];
    for (const obj of liveObjects) {
      if (!matchesElement(obj)) continue;
      const objectId = ensureObjectId(obj as any);
      if (objectId) objectIds.add(objectId);
    }
  } catch {}

  const elementId = String((normalizedElement as any)?.id ?? identifier ?? '').trim();
  const elementName = String((normalizedElement as any)?.name ?? identifier ?? '').trim();
  const familyId = String((normalizedElement as any)?.familyId ?? elementId ?? '').trim();
  const bring = Boolean((normalizedElement as any)?.bringToFront);
  const selectable = Boolean((normalizedElement as any)?.selectable === true);
  const evented = Boolean((normalizedElement as any)?.evented !== false);

  const patchRuntimeObject = (obj: any) => {
    if (!obj) return;
    try {
      if (elementId) (obj as any)._asukaElementId = elementId;
      if (familyId) (obj as any)._asukaElementFamilyId = familyId;
      if (elementName) (obj as any)._asukaElementName = elementName;
      (obj as any)._asukaElementIsInstance = true;
      (obj as any)._asukaBringToFront = bring;
      (obj as any).bringToFront = bring;
      obj.selectable = selectable;
      obj.evented = evented;
      (obj as any).hasControls = selectable;
      (obj as any).hasBorders = selectable;
      (obj as any).hoverCursor = evented ? (selectable ? 'move' : 'pointer') : 'default';
      try { obj.setCoords?.(); } catch {}
    } catch {}
  };

  try {
    const liveObjects = canvas.getObjects?.() ?? [];
    for (const obj of liveObjects) {
      const objectId = String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim();
      if ((objectId && objectIds.has(objectId)) || matchesElement(obj)) patchRuntimeObject(obj);
    }
  } catch {}

  const variationId = String(options?.variationId ?? '').trim();
  if (variationId && objectIds.size > 0) {
    await withHistoryMuted(async () => {
      for (const objectId of Array.from(objectIds)) {
        try {
          await (this as any).applyElementVariationToObject?.(objectId, elementId || elementName || identifier, variationId);
        } catch {}
      }
    });
  }

  try {
    const liveObjects = canvas.getObjects?.() ?? [];
    for (const obj of liveObjects) {
      const objectId = String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim();
      if ((objectId && objectIds.has(objectId)) || matchesElement(obj)) patchRuntimeObject(obj);
    }
    try { normalizeLayersByRules({ assignMissingOnly: false, canvasRef: canvas }); } catch {}
    try { canvas.requestRenderAll?.(); } catch {}
  } catch {}

  try { syncDocumentObjectsForView(activeViewId); } catch {}
  try { saveSnapshotForView(this as any, activeViewId); } catch {}
  try { store.bumpCanvasChangeTick?.(); } catch {}
  try { commitHistoryChange({ force: true, skipCanvasSync: true, reason: String(options?.source ?? 'elements.edit.active-view') }); } catch {}
},


async applyElementVariation(args: AsukaElementVariationApplyInput) {
  const payload = { ...args };
  // Allow interception/cancellation
  const ok = emitAsukaEventCancelable('before:elements.applyVariation', payload);
  if (!ok) return false;
  return await __withElementsLoaderGuard(async () => {
    try {
      let res: any = null;
      if (args.scope === 'object') {
        if (!args.objectId) return false;
        res = await (this as any).applyElementVariationToObject?.(args.objectId, args.elementId || args.name || '', args.variationId);
      } else {
        res = await (this as any).applyElementVariationAcrossDocument?.(args.name || args.elementId || '', args.variationId, args.objectId, args.variationPathNames, args.elementId);
        await synchronizeStoredElementVariationRuntime(args, {
          includeFabric: false,
          include3D: true,
          syncCatalog: true,
        });
      }
      emitAfter('elements.applyVariation', { ...payload, result: res });
      return true;
    } catch (e: any) {
      void 0;
      emitAsukaEvent('elements.applyVariation.error', { ...payload, error: String(e?.message ?? e) });
      return false;
    }
  });
},

async applyElementVariationAcrossDocument(name: string, variationId: string, sourceObjectId?: string, explicitPathNames?: string[], elementId?: string) {
  const variationRequestKey = `document:${String(elementId ?? name ?? sourceObjectId ?? '').trim()}`;
  const variationRequestVersion = __beginVariationRequest(variationRequestKey);
  const isCurrentVariationRequest = () => __isCurrentVariationRequest(variationRequestKey, variationRequestVersion);

  // Apply to the whole document (all views/canvases), not only the current view.
  // NOTE: when using a single Fabric canvas with per-view snapshots, the other views are NOT live canvases.
  // We must patch stored snapshots too, otherwise only the current view gets updated.
  const regenerateThumbnailsFromSnapshots = async (viewIds: string[]) => {
    const ids = Array.isArray(viewIds) ? Array.from(new Set(viewIds.map((v) => String(v)).filter(Boolean))) : [];
    if (ids.length === 0) return;

    const store = useAsukaStore.getState() as any;
    const setViewThumbnail = store.setViewThumbnail;
    if (typeof setViewThumbnail !== 'function') return;

    const main = (this as any).getCanvas?.();
    const w = Number(main?.getWidth?.() ?? main?.width ?? 0) || 0;
    const h = Number(main?.getHeight?.() ?? main?.height ?? 0) || 0;
    if (!w || !h) return;

    const el = document.createElement('canvas');
    el.width = w;
    el.height = h;
    const sc = new (fabric as any).StaticCanvas(el, { renderOnAddRemove: false });

    const waitForCanvasImagesReady = async (canvasLike: any, timeoutMs = 1200) => {
      try {
        const objs = canvasLike?.getObjects ? canvasLike.getObjects() : [];
        const pending: Promise<void>[] = [];
        for (const o of objs) {
          if (!o) continue;
          const isImg = o.type === 'image' || o.isType?.('image');
          if (!isImg) continue;
          const img = (o as any)?._originalElement || (o as any)?._element || (o as any)?.getElement?.();
          if (!img || typeof img !== 'object') continue;
          const complete = Boolean((img as any).complete);
          const naturalWidth = Number((img as any).naturalWidth ?? 0) || 0;
          if (complete && naturalWidth > 0) continue;
          pending.push(new Promise<void>((resolve) => {
            let done = false;
            const finish = () => {
              if (done) return;
              done = true;
              resolve();
            };
            try {
              const prevLoad = (img as any).onload;
              const prevError = (img as any).onerror;
              (img as any).onload = (...args: any[]) => {
                try { prevLoad?.(...args); } catch {}
                finish();
              };
              (img as any).onerror = (...args: any[]) => {
                try { prevError?.(...args); } catch {}
                finish();
              };
            } catch {}
            try { setTimeout(finish, timeoutMs); } catch { finish(); }
          }));
        }
        if (pending.length) await Promise.all(pending);
      } catch {}
    };

    try {
      const vt = (main as any)?.viewportTransform;
      if (vt && Array.isArray(vt)) (sc as any).viewportTransform = vt.slice();
    } catch {}

    for (const vid of ids) {
      const snap = getSnapshotForView(String(vid));
      if (!snap) continue;

      try {
        await sc.loadFromJSON(snap);
      } catch {}
      try { await waitForCanvasImagesReady(sc); } catch {}
      try { sc.requestRenderAll?.(); } catch {}
      try { sc.renderAll?.(); } catch {}

      try {
        const dataUrl = sc.toDataURL({ format: 'png', multiplier: 0.25 });
        if (dataUrl) setViewThumbnail(String(vid), dataUrl);
      } catch {
        // ignore per view
      }
    }

    try { sc.dispose?.(); } catch {}
  };

  let impactedViewIds: string[] = [];
  let sourceSyncKey = String(name ?? '').trim();
  const matchedVariationIdByObjectId = new Map<string, string>();
  {
    const store = useAsukaStore.getState() as any;
    const elementName = String(name ?? '').trim();
    const requestedVariationId = String(variationId ?? '').trim();

    const findVariationDeepLocal = (variations: any[], vid: string): any | null => {
      for (const v of variations ?? []) {
        if (!v) continue;
        if (String(v?.id ?? '') === String(vid)) return v;
        const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
        const found = kids.length ? findVariationDeepLocal(kids, vid) : null;
        if (found) return found;
      }
      return null;
    };

    const findVariationPathNamesLocal = (variations: any[], vid: string, trail: string[] = []): string[] | null => {
      for (const v of variations ?? []) {
        if (!v) continue;
        const nextTrail = [...trail, String((v as any)?.name ?? (v as any)?.id ?? '').trim()].filter(Boolean);
        if (String((v as any)?.id ?? '') === String(vid)) return nextTrail;
        const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
        const found = kids.length ? findVariationPathNamesLocal(kids, vid, nextTrail) : null;
        if (found) return found;
      }
      return null;
    };

    const findVariationByNamePathLocal = (variations: any[], names: string[], depth = 0): any | null => {
      const expected = String(names?.[depth] ?? '').trim();
      if (!expected) return null;
      for (const v of variations ?? []) {
        if (!v) continue;
        const label = String((v as any)?.name ?? (v as any)?.id ?? '').trim();
        if (label !== expected) continue;
        if (depth >= names.length - 1) return v;
        const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
        const found = kids.length ? findVariationByNamePathLocal(kids, names, depth + 1) : null;
        if (found) return found;
      }
      return null;
    };

    const sourceElement = resolveElementRecord(store, String(elementId ?? '').trim() || elementName, elementName);
    const sourceElementId = String((sourceElement as any)?.id ?? elementName ?? '').trim();
    const sourceFamilyIdFromElement = String((sourceElement as any)?.familyId ?? sourceElementId ?? '').trim();
    sourceSyncKey = sourceFamilyIdFromElement || sourceElementId || String(elementName).trim() || elementName;
    const sourceVariations = Array.isArray((sourceElement as any)?.variations) ? (sourceElement as any).variations : [];
    const requestedVariation = requestedVariationId ? findVariationDeepLocal(sourceVariations, requestedVariationId) : null;
    const normalizeNames = (list?: any[]) => (Array.isArray(list) ? list.map((x: any) => String(x ?? '').trim()).filter(Boolean) : []);
    const storeState = useAsukaStore.getState() as any;
    const sourceViewId = (() => {
      const wanted = String(sourceObjectId ?? '').trim();
      if (!wanted) return '';
      const byView = (storeState?.documentObjectsByView && typeof storeState.documentObjectsByView === 'object') ? storeState.documentObjectsByView : {};
      for (const [vid, objects] of Object.entries(byView)) {
        if (!Array.isArray(objects)) continue;
        const found = objects.find((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim() === wanted);
        if (found) return String(vid);
      }
      return '';
    })();
    const sourceViewName = (() => {
      const views = Array.isArray(storeState?.views) ? storeState.views : [];
      const found = views.find((v: any) => String(v?.id ?? '').trim() === String(sourceViewId));
      return String(found?.name ?? '').trim();
    })();
    const requestedPathNames = (() => {
      const explicit = normalizeNames(explicitPathNames);
      if (explicit.length) return explicit;
      const byId = requestedVariationId ? (findVariationPathNamesLocal(sourceVariations, requestedVariationId) ?? []) : [];
      return normalizeNames(byId);
    })();
    const requestedTailNames = requestedPathNames.length > 1 ? requestedPathNames.slice(1) : requestedPathNames.slice();

    debugElementVariations('apply-document-start', {
      elementName,
      variationId: requestedVariationId,
      sourceObjectId: String(sourceObjectId ?? ''),
      sourceViewId,
      sourceViewName,
      explicitPathNames: normalizeNames(explicitPathNames),
      requestedPathNames,
      requestedTailNames,
      sourceSyncKey,
      requestedVariation: requestedVariation ? { id: String((requestedVariation as any)?.id ?? ''), name: String((requestedVariation as any)?.name ?? ''), image: String((requestedVariation as any)?.image ?? '') } : null,
      sourceRootVariations: (sourceVariations ?? []).map((v: any) => ({ id: String(v?.id ?? ''), name: String(v?.name ?? ''), image: String(v?.image ?? ''), childCount: Array.isArray(v?.variations) ? v.variations.length : 0 })),
    });

    const replacementGeometryCache = new Map<string, Promise<{ width: number; height: number } | null>>();
    const getReplacementGeometry = async (imageUrl: string): Promise<{ width: number; height: number } | null> => {
      const key = String(imageUrl ?? '').trim();
      if (!key) return null;
      if (!replacementGeometryCache.has(key)) {
        replacementGeometryCache.set(key, (async () => {
          try {
            const replacement = await __createFabricObjectFromUrl(key);
            const width = Number((replacement as any)?.width ?? 0) || 0;
            const height = Number((replacement as any)?.height ?? 0) || 0;
            try { (replacement as any)?.dispose?.(); } catch {}
            return width > 0 && height > 0 ? { width, height } : null;
          } catch {
            return null;
          }
        })());
      }
      try { return await replacementGeometryCache.get(key)!; } catch { return null; }
    };

    const applySerializedVariationGeometry = async (obj: any, imageUrl: string) => {
      const geometry = await getReplacementGeometry(imageUrl);
      if (!geometry || !isCurrentVariationRequest()) return;
      const frame = ensureFabricVariationFrame(obj);
      if (!frame) return;
      applyFabricVariationFrame(obj, frame, geometry);
    };

    try {
      const byView = (store?.documentObjectsByView && typeof store.documentObjectsByView === 'object') ? store.documentObjectsByView : {};
      const nextByView: Record<string, any[]> = {};
      const impacted = new Set<string>();

      for (const [viewIdRaw, objectsRaw] of Object.entries(byView)) {
        const viewId = String(viewIdRaw);
        const objects = Array.isArray(objectsRaw) ? objectsRaw : [];
        let changed = false;
        const nextObjects: any[] = [];

        for (const obj of objects) {
          if (!obj || typeof obj !== 'object') {
            nextObjects.push(obj);
            continue;
          }

          const objectElementId = String((obj as any)._asukaElementId ?? '').trim();
          const objectFamilyId = String((obj as any)._asukaElementFamilyId ?? '').trim();
          if ((objectFamilyId || objectElementId) !== sourceSyncKey) {
            nextObjects.push(obj);
            continue;
          }

          const targetElement = resolveElementRecord(store, objectElementId, String((obj as any)._asukaElementName ?? ''));
          const targetVariations = Array.isArray((targetElement as any)?.variations) ? (targetElement as any).variations : [];
          let matchedVariation: any = null;
          const viewName = (() => {
            const views = Array.isArray(storeState?.views) ? storeState.views : [];
            const found = views.find((v: any) => String(v?.id ?? '').trim() === viewId);
            return String(found?.name ?? '').trim();
          })();
          if (requestedPathNames.length) {
            matchedVariation = findVariationByNamePathLocal(targetVariations, requestedPathNames);
          }
          if (!matchedVariation && viewName && requestedTailNames.length) {
            matchedVariation = findVariationByNamePathLocal(targetVariations, [viewName, ...requestedTailNames]);
          }
          if (!matchedVariation && sourceViewName && viewName && requestedPathNames.length && requestedPathNames[0] === sourceViewName && requestedTailNames.length) {
            matchedVariation = findVariationByNamePathLocal(targetVariations, [viewName, ...requestedTailNames]);
          }
          if (!matchedVariation && requestedVariation) {
            const wantedName = String((requestedVariation as any)?.name ?? '').trim();
            if (wantedName) {
              const walk = (list: any[]): any | null => {
                for (const item of list ?? []) {
                  if (!item) continue;
                  if (String((item as any)?.name ?? '').trim() === wantedName) return item;
                  const kids = Array.isArray((item as any)?.variations) ? (item as any).variations : [];
                  const found = kids.length ? walk(kids) : null;
                  if (found) return found;
                }
                return null;
              };
              matchedVariation = walk(targetVariations);
            }
          }
          if (!matchedVariation && requestedVariationId) matchedVariation = findVariationDeepLocal(targetVariations, requestedVariationId);
          if (!matchedVariation) {
            debugElementVariations('apply-document-no-match-for-object', {
              viewId,
              viewName,
              objectId: String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim(),
              objectVariationId: String((obj as any)?._asukaElementVariationId ?? ''),
              objectSrc: String((obj as any)?.src ?? ''),
              requestedVariationId,
              requestedPathNames,
              targetVariationRoots: (targetVariations ?? []).map((v: any) => ({ id: String(v?.id ?? ''), name: String(v?.name ?? ''), image: String(v?.image ?? ''), childCount: Array.isArray(v?.variations) ? v.variations.length : 0 })),
            });
            nextObjects.push(obj);
            continue;
          }

          const imageUrl = String((matchedVariation as any)?.image || (targetElement as any)?.image || '');
          debugElementVariations('apply-document-match-for-object', {
            viewId,
            viewName,
            objectId: String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim(),
            sourceObjectId: String(sourceObjectId ?? ''),
            objectVariationIdBefore: String((obj as any)?._asukaElementVariationId ?? ''),
            objectSrcBefore: String((obj as any)?.src ?? ''),
            matchedVariation: { id: String((matchedVariation as any)?.id ?? ''), name: String((matchedVariation as any)?.name ?? ''), image: String((matchedVariation as any)?.image ?? '') },
            imageUrl,
            requestedVariationId,
            requestedPathNames,
          });
          const nextObj = { ...obj } as any;
          const matchedVariationId = String((matchedVariation as any)?.id ?? requestedVariationId).trim();
          nextObj._asukaElementVariationId = matchedVariationId;
          nextObj._asukaElementIsInstance = true;
          nextObj._asukaElementId = String((targetElement as any)?.id ?? objectElementId ?? '');
          nextObj._asukaElementFamilyId = String((targetElement as any)?.familyId ?? objectFamilyId ?? sourceSyncKey);
          nextObj._asukaElementName = String((targetElement as any)?.name ?? (obj as any)._asukaElementName ?? '');
          const elementPrice = getElementPriceBreakdown(targetElement, matchedVariation);
          nextObj._asukaElementBasePrice = elementPrice.base;
          nextObj._asukaElementVariationPrice = elementPrice.variation;
          nextObj.price = elementPrice.total;
          if (imageUrl && typeof nextObj.src === 'string') {
            nextObj.src = imageUrl;
          }
          if (imageUrl) {
            await applySerializedVariationGeometry(nextObj, imageUrl);
            if (!isCurrentVariationRequest()) return;
          }
          const objectId = String((nextObj as any)?._asukaObjectId ?? (nextObj as any)?.id ?? '').trim();
          if (objectId && matchedVariationId) {
            matchedVariationIdByObjectId.set(objectId, matchedVariationId);
          }
          changed = true;
          impacted.add(viewId);
          nextObjects.push(nextObj);
        }

        nextByView[viewId] = changed ? nextObjects : objects;
      }

      impactedViewIds = Array.from(impacted);
      if (impactedViewIds.length) {
        if (!isCurrentVariationRequest()) return;
        try { useAsukaStore.setState({ documentObjectsByView: nextByView } as any); } catch {}
        for (const vid of impactedViewIds) {
          try { syncSnapshotObjectsFromDocumentView(String(vid)); } catch {}
        }
      }
    } catch {
      impactedViewIds = [];
    }

    // Keep the existing tick for other UI that relies on it.
    try { store.bumpCanvasChangeTick?.(); } catch {}
  }
  const canvases: any[] = (this as any).getCanvases ? (this as any).getCanvases('toArray') : [ (this as any).getCanvas?.() ];
  const targets = Array.from(new Set(
    getAllDocumentObjectRecords((obj: any) => {
      const objectFamilyId = String((obj as any)?._asukaElementFamilyId ?? '').trim();
      const objectElementId = String((obj as any)?._asukaElementId ?? '').trim();
      return (objectFamilyId || objectElementId) === String(sourceSyncKey ?? name).trim();
    })
      .map(({ object }) => String((object as any)?._asukaObjectId ?? (object as any)?.id ?? '').trim())
      .filter(Boolean),
  ));

  debugElementVariations('apply-document-live-targets', {
    name: String(name ?? ''),
    requestedVariationId: String(variationId ?? ''),
    targets,
    matchedVariationIdByObjectId: Array.from(matchedVariationIdByObjectId.entries()),
    impactedViewIds,
  });
  await withHistoryMuted(async () => {
    for (const oid of targets) {
      if (!isCurrentVariationRequest()) return;
      const resolvedVariationId = String(matchedVariationIdByObjectId.get(String(oid).trim()) ?? variationId ?? '').trim();
      const targetObj = getAllDocumentObjectRecords((obj: any) => String((obj as any)?._asukaObjectId ?? (obj as any)?.id ?? '').trim() === String(oid).trim())[0]?.object ?? null;
      const targetElementId = String((targetObj as any)?._asukaElementId ?? '').trim();
      debugElementVariations('apply-document-live-target', { objectId: oid, requestedVariationId: String(variationId ?? ''), resolvedVariationId, name, targetElementId });
      if (!resolvedVariationId) continue;
      await (this as any).applyElementVariationToObject?.(oid, targetElementId || name, resolvedVariationId);
      if (!isCurrentVariationRequest()) return;
    }
  });
  if (!isCurrentVariationRequest()) return;
  const activeViewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
  if (activeViewId && impactedViewIds.includes(activeViewId)) {
    try { saveSnapshotForView(this as any, activeViewId); } catch {}
  }
  if (targets.length > 0) {
    try { commitHistoryChange({ force: true }); } catch {}
  }

  // IMPORTANT: regenerate thumbnails for impacted (non-active) views immediately.
  // Do NOT rely on switching the view to update thumbnails.
  try {
    await regenerateThumbnailsFromSnapshots(impactedViewIds);
  } catch {}

  for (const c of canvases) {
    try { c?.requestRenderAll?.(); } catch {}
  }
},


async applyElementVariationToObject(objectId: string, identifier: string, variationId: string) {
  const store = useAsukaStore.getState() as any;
  const el = resolveElementRecord(store, identifier);
  if (!el) return;
  const name = String((el as any)?.name ?? identifier ?? '');


  const findVariationDeep = (variations: any[], vid: string): any | null => {
    for (const v of variations ?? []) {
      if (!v) continue;
      if (String(v?.id ?? '') === String(vid)) return v;
      const kids = Array.isArray((v as any)?.variations) ? (v as any).variations : [];
      const found = kids.length ? findVariationDeep(kids, vid) : null;
      if (found) return found;
    }
    return null;
  };

  const vars = Array.isArray((el as any).variations) ? (el as any).variations : [];
  const variation = findVariationDeep(vars, variationId);
  if (!variation) return;

  const src = String((variation as any)?.image || (el as any)?.image || '');
  if (!src) {
    void 0;
    return;
  }

  const createFromUrl = async (url: string): Promise<any> => __createFabricObjectFromUrl(url);

  // Object ids are global across views. Locate the object on the correct canvas.
  const canvases: any[] = (this as any).getCanvases ? (this as any).getCanvases('toArray') : [canvas];
  let targetCanvas: any = null;
  let target: any = null;
  for (const c of canvases) {
    const found = c?.getObjects?.().find((o: any) => String(o?._asukaObjectId ?? '') === String(objectId));
    if (found) {
      targetCanvas = c;
      target = found;
      break;
    }
  }
  if (!target || !targetCanvas) return;

  const variationRequestKey = `object:${String(objectId).trim()}`;
  const variationRequestVersion = __beginVariationRequest(variationRequestKey);
  const variationFrame = ensureFabricVariationFrame(target);
  if (!variationFrame) return;

  // Create the new object for the selected variation.
  const createdObj = await createFromUrl(src);
  if (!createdObj) return;
  let newObj: any = createdObj;
  if (!__isCurrentVariationRequest(variationRequestKey, variationRequestVersion)) {
    try { newObj?.dispose?.(); } catch {}
    return;
  }
  if (newObj && typeof (newObj as any).render !== 'function') { void 0; return; }

  // Element instances must remain non-selectable across swaps/undo/redo.

  // Keep ids / metadata
  (newObj as any)._asukaObjectId = String(target._asukaObjectId ?? objectId);
  (newObj as any)._asukaElementIsInstance = true;
  (newObj as any)._asukaElementId = String((el as any)?.id ?? (target as any)._asukaElementId ?? '');
  (newObj as any)._asukaElementFamilyId = String((el as any)?.familyId ?? (target as any)._asukaElementFamilyId ?? (el as any)?.id ?? '');
  (newObj as any)._asukaElementName = String(name);
  (newObj as any)._asukaElementVariationId = String(variationId);
  const elementPrice = getElementPriceBreakdown(el, variation);
  (newObj as any)._asukaElementBasePrice = elementPrice.base;
  (newObj as any)._asukaElementVariationPrice = elementPrice.variation;
  (newObj as any).price = elementPrice.total;
  (newObj as any).itemGroup = (target as any).itemGroup;
  (newObj as any)._asukaZoneId = (target as any)._asukaZoneId;
  (newObj as any)._asukaIgnoreDrawingZones = (target as any)._asukaIgnoreDrawingZones;
  (newObj as any)._asukaBringToFront = (target as any)._asukaBringToFront;
  (newObj as any).bringToFront = Boolean((target as any).bringToFront);
  (newObj as any)._asukaOrderingSeq = (target as any)._asukaOrderingSeq;
  (newObj as any).layerIndex = (target as any).layerIndex;

  const stableGeometry = applyFabricVariationFrame(newObj, variationFrame, {
    width: Number((newObj as any)?.width ?? 0),
    height: Number((newObj as any)?.height ?? 0),
  });
  if (!stableGeometry) {
    try { newObj?.dispose?.(); } catch {}
    return;
  }

  try {
    const nextSelectable = Boolean((target as any)?.selectable === true);
    const nextEvented = Boolean((target as any)?.evented !== false);
    configureFabricElementInteraction(newObj, {
      selectable: nextSelectable,
      evented: nextEvented,
      targetFindTolerance: Number((target as any)?.targetFindTolerance),
    });
  } catch {}

  // Replace at same z-index.
  const objects = targetCanvas.getObjects?.() ?? [];
  const idx = objects.indexOf(target);
  const wasActive = targetCanvas.getActiveObject?.() === target;

  if (!__isCurrentVariationRequest(variationRequestKey, variationRequestVersion)) {
    try { newObj?.dispose?.(); } catch {}
    return;
  }

  await withHistoryMuted(async () => {
    try { targetCanvas.remove(target); } catch {}
    // Safety: never insert non-fabric objects into the canvas render pipeline
    if (newObj && typeof (newObj as any).render !== 'function') {
      void 0;
      try {
        const coerced = (globalThis as any).__asukaSafeGroupSvgAny?.(newObj as any, null) ?? null;
        if (coerced && typeof (coerced as any).render === 'function') {
          newObj = coerced as any;
        } else {
          void 0;
          return;
        }
      } catch {
        void 0;
        return;
      }
    }

    // `canvas.add()` synchronously triggers the external-object preparation
    // hook. This marker is a second guard against fitting a replacement as if
    // it were a newly uploaded layer.
    try { (newObj as any).__asukaSkipAutoFit = true; } catch {}
    const added = addFabricObjectAtIndex(targetCanvas, newObj, idx);
    try { delete (newObj as any).__asukaSkipAutoFit; } catch {
      try { (newObj as any).__asukaSkipAutoFit = false; } catch {}
    }
    if (!added) return;
    try { newObj.setCoords?.(); } catch {}

    applyClipForObject(newObj);
    // Element instances are structural/clickable objects: do not install Asuka
    // transform controls during a variation swap. Installing controls here after
    // Fabric has already calculated coords can leave stale `oCoords` entries and
    // crash Fabric's `findControl()` on the next pointer event.
    try { sanitizeAsukaObjectControls(newObj); } catch {}

    if (wasActive) {
      try { targetCanvas.setActiveObject(newObj); } catch {}
    }

    try { scheduleNormalizeLayers({ assignMissingOnly: false, canvasRef: targetCanvas }); } catch {}
    targetCanvas.requestRenderAll();
  });
  if (!isHistoryMuted()) {
    commitHistoryChange({ force: true });
  }
},

clear() {
  if (isDesignerPermissionDenied('canClearCanvas', { action: 'designer.clear' })) return;
  emitBefore('canvas.clear', null, { instanceId });

  const activeViewId = String((useAsukaStore.getState() as any)?.activeViewId ?? '').trim();
  if (!activeViewId) return;

  setDocumentObjectsForViewState(activeViewId, []);
  try { useAsukaStore.getState().setDocumentSelection?.({ activeObjectIds: [] } as any); } catch {}
  canvas.backgroundColor = options.backgroundColor ?? '#ffffff';
  try { canvas.discardActiveObject?.(); } catch {}
  rebuildViewFromDocumentState(activeViewId);
  saveSnapshot();
  canvas.requestRenderAll();

  emitAfter('canvas.clear', null, { instanceId });
},

async undo() {
  if (isDesignerPermissionDenied('canUndo', { action: 'designer.undo' })) return;
  const viewId = getHistoryViewKey();
  getHistoryTrack(viewId);
  if (historyIndex <= 0) {

    return;
  }

  const fromIndex = historyIndex;
  const toIndex = historyIndex - 1;


  emitBefore('canvas.history.undo', { fromIndex, toIndex, viewId }, { instanceId });
  await loadSnapshot(toIndex, viewId);
  emitAfter('canvas.history.undo', { fromIndex, toIndex, viewId }, { instanceId });
},

async redo() {
  if (isDesignerPermissionDenied('canRedo', { action: 'designer.redo' })) return;
  const viewId = getHistoryViewKey();
  getHistoryTrack(viewId);
  if (historyIndex >= historyStack.length - 1) {

    return;
  }

  const fromIndex = historyIndex;
  const toIndex = historyIndex + 1;


  emitBefore('canvas.history.redo', { fromIndex, toIndex, viewId }, { instanceId });
  await loadSnapshot(toIndex, viewId);
  emitAfter('canvas.history.redo', { fromIndex, toIndex, viewId }, { instanceId });
},

async reset() {
  if (isDesignerPermissionDenied('canReset', { action: 'designer.reset' })) return;
  const viewId = getHistoryViewKey();
  getHistoryTrack(viewId);
  if (historyStack.length === 0) return;

  const fromIndex = historyIndex;
  const toIndex = 0;

  emitBefore('canvas.history.reset', { fromIndex, toIndex, viewId }, { instanceId });
  await loadSnapshot(toIndex, viewId);
  emitAfter('canvas.history.reset', { fromIndex, toIndex, viewId }, { instanceId });
},

canUndo() {
  getHistoryTrack();
  return historyIndex > 0;
},

canRedo() {
  getHistoryTrack();
  return historyIndex < historyStack.length - 1;
},

getHistoryInfo(viewId?: string | null) {
      const info = getHistoryInfoForView(viewId);
      return {
        index: info.index,
        length: info.length,
        canUndo: info.canUndo,
        canRedo: info.canRedo,
        maxSnapshots: info.maxSnapshots,
        engine: info.engine,
        scope: info.scope,
        viewId: info.viewId,
      } as any;
    },

    async goToHistory(index: number) {
      const viewId = getHistoryViewKey();
      getHistoryTrack(viewId);
      if (index < historyIndex && isDesignerPermissionDenied('canUndo', { action: 'designer.goToHistory', index, historyIndex, viewId })) return;
      if (index > historyIndex && isDesignerPermissionDenied('canRedo', { action: 'designer.goToHistory', index, historyIndex, viewId })) return;
      if (index === 0 && isDesignerPermissionDenied('canReset', { action: 'designer.goToHistory', index, historyIndex, viewId })) return;
      if (index < 0 || index >= historyStack.length) {
        return;
      }
      await loadSnapshot(index, viewId);
    },

    getActiveObjectFill() {
      const active: any = canvas.getActiveObject();
      if (!active) return null;

      // Curved text group: read from metadata or first letter
      if (isCurvedTextObject(active)) {
        const data = active._asukaCurvedTextData || {};
        const source = data.source || {};
        if (typeof source.fill === 'string') return source.fill;
        const first = Array.isArray(active._objects) ? active._objects[0] : null;
        const f = first?.fill;
        return typeof f === 'string' ? f : null;
      }

      const fill = active.fill;
      return typeof fill === 'string' ? fill : null;
    },

        setActiveObjectFill(color: string) {
      if (isDesignerPermissionDenied('canUseColors', { action: 'designer.setActiveObjectFill', color })) return;
      if (isDesignerPermissionDenied('canEditColors', { action: 'designer.setActiveObjectFill', color })) return;
      if (isDesignerPermissionDenied('canEditObjectColors', { action: 'designer.setActiveObjectFill', color })) return;
      const active: any = canvas.getActiveObject();
      if (!active) return;

      const objectId = String((active as any)?._asukaObjectId ?? (active as any)?.id ?? '');

      // Curved text group: update runtime metadata first, then persist back to document state.
      if (isCurvedTextObject(active) && Array.isArray(active._objects)) {
        active._objects.forEach((t: any) => {
          if (t && typeof t.set === 'function') t.set({ fill: color });
          else if (t) t.fill = color;
        });

        const data = active._asukaCurvedTextData || {};
        const source = data.source || {};
        active._asukaCurvedTextData = { ...data, source: { ...source, fill: color } };

        active._objects.forEach((t: any) => t?.setCoords?.());
        active.setCoords?.();

        if (objectId) {
          replaceDocumentObjectFromRuntime(objectId, active, { rebuild: false });
          try { canvas.setActiveObject?.(active); } catch {}
          try { canvas.requestRenderAll?.(); } catch {}
          try { scheduleHistorySnapshot({ force: true }); } catch {}
          return;
        }

        canvas.requestRenderAll?.();
        commitHistoryChange({ force: true });
        return;
      }

      if (objectId) {
        try {
          if (typeof active.set === 'function') active.set('fill', color);
          else active.fill = color;
          active.setCoords?.();
          replaceDocumentObjectFromRuntime(objectId, active, { rebuild: false });
          canvas.setActiveObject?.(active);
          canvas.requestRenderAll?.();
          scheduleHistorySnapshot({ force: true });
        } catch {}
        return;
      }

      if (typeof active.set === 'function') {
        active.set('fill', color);
      } else {
        active.fill = color;
      }
      canvas.requestRenderAll?.();
      commitHistoryChange({ force: true });
    },

    /**
     * Applies a fill to a specific sub-path in a grouped SVG, and optionally harmonizes
     * the rest of the document according to the global color scope.
     *
     * - scope=object   -> only affects the provided object (its internal _objects)
     * - scope=canvas   -> affects all objects on current canvas (groups + text, etc.)
     * - scope=document -> affects current canvas + stored snapshots of other views
     */
    async setPathFill(value: string, index: number, method: string | null = null, object?: any): Promise<void> {
      if (isDesignerPermissionDenied('canUseColors', { action: 'designer.setPathFill', value, index, method })) return;
      if (isDesignerPermissionDenied('canEditColors', { action: 'designer.setPathFill', value, index, method })) return;
      if (isDesignerPermissionDenied('canEditObjectColors', { action: 'designer.setPathFill', value, index, method })) return;
      const targetObj: any = object ?? canvas.getActiveObject();
      if (!targetObj) return;

      const scope = getColorScope();
      const palette = getPaletteForActiveMethodOrFallback();

      const isValidUrl = (v: string): boolean => {
        try {

          new URL(v);
          return true;
        } catch {
          return false;
        }
      };

      const getFillString = (fill: any): string | null => {
        if (!fill) return null;
        if (typeof fill === 'string') return fill;
        const src = fill?.source?.src;
        return typeof src === 'string' ? src : null;
      };

      const applyFillToPath = async (path: any, fillValue: string): Promise<void> => {
        if (!path) return;
        if (isValidUrl(fillValue)) {
          const img = await new Promise<HTMLImageElement | null>((resolve) => {
            try {
              fabric.util.loadImage(fillValue, (image: any) => {
                resolve((image as any) ?? null);
              });
            } catch {
              resolve(null);
            }
          });
          if (img) {
            const sx = Number(path.scaleX) || 1;
            const sy = Number(path.scaleY) || 1;
            const pattern = new fabric.Pattern({
              source: img,
              repeat: 'repeat',
              patternTransform: [1 / sx, 0, 0, 1 / sy, 0, 0],
            });
            path.set?.({ fill: pattern });
            if (!path.set) path.fill = pattern;
            return;
          }
        }

        path.set?.({ fill: fillValue });
        if (!path.set) path.fill = fillValue;
      };

      const harmonizeGroup = async (group: any, isOriginal = false): Promise<void> => {
        const paths: any[] = Array.isArray(group?._objects) ? group._objects : [];
        if (!paths.length) return;

        // Apply the requested color to the selected path only on the original object.
        if (Number.isFinite(index) && paths[index]) {
          const actual = isOriginal ? value : null;
          if (actual) {
            await applyFillToPath(paths[index], actual);
          }
        }

        // Harmonize other paths against the palette (closest match to their current fill).
        for (let i = 0; i < paths.length; i += 1) {
          const p = paths[i];
          if (!p) continue;
          if (isOriginal && i === index) continue;

          const current = getFillString(p.fill);
          if (!current) continue;
          const similar = await getSimilarColorAsync(current, palette);
          if (!similar) continue;
          await applyFillToPath(p, similar.value);
        }

        group.set?.({ printingMethodId: method });
        if (!group.set) group.printingMethodId = method;
        group._objects?.forEach?.((t: any) => t?.setCoords?.());
        group.setCoords?.();
      };

      const harmonizeObjectFill = async (obj: any): Promise<void> => {
        if (!obj) return;
        if (Array.isArray(obj._objects) && obj._objects.length) {
          await harmonizeGroup(obj, obj === targetObj);
          return;
        }

        // Normal objects (text, shapes...): harmonize their fill to closest palette.
        const fill = getFillString(obj.fill);
        if (!fill) return;
        const similar = await getSimilarColorAsync(fill, palette);
        if (!similar) return;

        obj.set?.({ fill: similar.value });
        if (!obj.set) obj.fill = similar.value;
        obj.setCoords?.();
      };

      if (scope === 'object') {
        await harmonizeObjectFill(targetObj);
        const objectId = String((targetObj as any)?._asukaObjectId ?? (targetObj as any)?.id ?? '');
        if (objectId) {
          replaceDocumentObjectFromRuntime(objectId, targetObj, { rebuild: false });
          try { canvas.setActiveObject?.(targetObj); } catch {}
          try { canvas.requestRenderAll?.(); } catch {}
          try { scheduleHistorySnapshot({ force: true }); } catch {}
          return;
        }
        canvas.requestRenderAll?.();
        commitHistoryChange({ force: true });
        return;
      }

      // canvas / document: apply on all objects in current canvas
      const objs = canvas.getObjects?.() ?? [];
      if (ASUKA_CONTROLS_DEBUG) void 0;
      for (const o of objs) {
        // skip internal helpers / zones
        const role = (o as any)?._asukaRole;
        if (role === 'drawingZone' || role === 'snapVertical' || role === 'snapHorizontal') continue;
        await harmonizeObjectFill(o);
      }
      try { syncDocumentObjectsForView(); } catch {}
      canvas.requestRenderAll?.();
      commitHistoryChange({ force: true });

      if (scope === 'document') {
        // Also harmonize stored snapshots for non-active views.
        try {
          await harmonizeSnapshotsAcrossDocument(palette);
        } catch {
          // ignore
        }
      }
    },

    patchActiveTextStyle(patch: Record<string, any>) {
      if (isDesignerPatchDenied(patch, { action: 'designer.patchActiveTextStyle' })) return;
      const active: any = canvas.getActiveObject();
      if (!active) return;
      const nextPatch = (patch && typeof patch === 'object') ? { ...patch } : {};
      if (nextPatch.fontFamily) Object.assign(nextPatch, getFontMetadataPatchForFamily(String(nextPatch.fontFamily), active));
      const objectId = String((active as any)?._asukaObjectId ?? (active as any)?.id ?? '');

      // Curved text group: patch all letters and metadata, then re-layout if needed.
      if (isCurvedTextObject(active) && Array.isArray(active._objects)) {
        active._objects.forEach((t: any) => {
          if (!t) return;
          if (typeof t.set === 'function') t.set(nextPatch);
          else Object.assign(t, nextPatch);
        });

        const data = active._asukaCurvedTextData || {};
        const source = data.source || {};
        active._asukaCurvedTextData = { ...data, source: { ...source, ...nextPatch } };

        const affectsLayout = Object.keys(nextPatch).some((k) =>
          ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'charSpacing', 'text', 'styles', 'scaleX', 'scaleY'].includes(k),
        );

        if (affectsLayout) {
          updateCurvedGroup(active, {
            radius: data.radius,
            bend: data.bend,
            autoFit: data.autoFit,
            type: data.type,
          });
          if (objectId) {
            try { replaceDocumentObjectFromRuntime(objectId, active, { rebuild: true }); } catch {}
          }
          return;
        }

        active._objects.forEach((t: any) => t?.setCoords?.());
        active.setCoords?.();
        if (objectId) {
          replaceDocumentObjectFromRuntime(objectId, active, { rebuild: false });
          try { canvas.setActiveObject?.(active); } catch {}
          try { canvas.requestRenderAll?.(); } catch {}
          try { scheduleHistorySnapshot({ force: true }); } catch {}
          return;
        }
        canvas.requestRenderAll?.();
        commitHistoryChange({ force: true });
        return;
      }

      if (objectId) {
        (instance as any).updateObject?.(objectId, nextPatch);
        return;
      }

      if (typeof active.set === 'function') active.set(nextPatch);
      else Object.assign(active, nextPatch);
      try {
        applyFontMetadataToObject(active, nextPatch.fontFamily);
        if (shouldAutoSizeTextboxForPatch(nextPatch)) autoSizeTextboxToContent(active);
      } catch {}

      active.setCoords?.();
      canvas.requestRenderAll?.();
      commitHistoryChange({ force: true });
    },

deleteActiveObject() {
  if (isDesignerPermissionDenied('canDeleteObject', { action: 'designer.deleteActiveObject' })) return;
  const active: any = canvas.getActiveObject?.();
  if (!active) return;

  const isProtectedRole = (obj: any) => {
    const role = obj?._asukaRole;
    return role === 'drawingZone' || role === 'snapVertical' || role === 'snapHorizontal';
  };

  const removeElementInstance = (obj: any) => {
    const elementName = String(obj?._asukaElementName ?? '').trim();
    if (!elementName) return false;
    try {
      (useAsukaStore.getState() as any).removeElement?.(elementName);
    } catch {}
    return true;
  };

  const selectedObjects = (() => {
    try {
      const objs = canvas.getActiveObjects?.();
      if (Array.isArray(objs) && objs.length) return objs.filter((obj: any) => !!obj);
    } catch {}
    try {
      if ((active?.type === 'activeSelection' || active?.isType?.('activeselection') || active?.isType?.('activeSelection')) && typeof active.getObjects === 'function') {
        return (active.getObjects?.() || []).filter((obj: any) => !!obj);
      }
    } catch {}
    return active ? [active] as any[] : [] as any[];
  })();

  if (selectedObjects.length > 1) {
    const removable = selectedObjects.filter((obj: any) => !isProtectedRole(obj));
    const objectIds: string[] = [];
    const hadFixedSlotContent = removable.some((obj: any) => obj?._asukaFixedSlotId && obj?._asukaFixedSlotIsContent);

    try { canvas.discardActiveObject?.(); } catch {}
    emitBefore('canvas.object.remove', { batch: true }, { instanceId });
    try { flushPendingHistorySnapshot({ force: true, reason: 'deleteActiveObject:batch-pre-delete' }); } catch {}

    for (const obj of removable) {
      if (removeElementInstance(obj)) continue;
      const objectId = String(ensureObjectId(obj) ?? '').trim();
      if (objectId) objectIds.push(objectId);
      try { canvas.remove?.(obj); } catch {}
      try { disposeFabricObjectDeep(obj); } catch {}
    }

    try { syncDocumentObjectsForView(); } catch {}
    try { useAsukaStore.getState().setDocumentSelection?.({ activeObjectIds: [] } as any); } catch {}
    try {
      if (hadFixedSlotContent) {
        fixedSlotsRuntime?.ensureIndicatorsForSlots?.();
        useAsukaStore.getState().setFixedSlotActive?.(null);
      }
    } catch {}
    try { canvas.discardActiveObject?.(); } catch {}
    try { canvas.requestRenderAll?.(); } catch {}
    try { commitHistorySnapshotNow({ force: true, skipCanvasSync: true, reason: 'deleteActiveObject:batch-post-delete' }); } catch {}
    emitAfter('canvas.object.remove', { objectIds, batch: true }, { instanceId });
    return;
  }

  if (isProtectedRole(active)) return;
  try { flushPendingHistorySnapshot({ force: true, reason: 'deleteActiveObject:single-pre-delete' }); } catch {}
  if (removeElementInstance(active)) return;

  const objectId = ensureObjectId(active);
  try { canvas.discardActiveObject(); } catch {}
  (instance as any).removeObject?.(objectId);
},

duplicateActiveObject() {
  if (isDesignerPermissionDenied('canDuplicateObject', { action: 'designer.duplicateActiveObject' })) return;
  const active: any = canvas.getActiveObject?.();
  if (!active) return;

  const cloneOne = (obj: any) => new Promise<any>((resolve) => {
    try {
      const maybePromise = obj?.clone?.();
      if (maybePromise && typeof maybePromise.then === 'function') {
        maybePromise.then((cloned: any) => resolve(cloned)).catch(() => resolve(null));
        return;
      }
      if (typeof obj?.clone === 'function') {
        obj.clone((cloned: any) => resolve(cloned));
        return;
      }
    } catch {}
    resolve(null);
  });

  const prepareClone = (source: any, cloned: any, _index: number) => {
    if (!cloned) return null;
    try { resetCloneObjectIdentityDeep(cloned); } catch {}
    try { ensureObjectIdsDeep(cloned); } catch {}
    try {
      cloned.set?.({
        left: Number(source?.left ?? 0) + 24,
        top: Number(source?.top ?? 0) + 24,
      });
    } catch {
      try {
        cloned.left = Number(source?.left ?? 0) + 24;
        cloned.top = Number(source?.top ?? 0) + 24;
      } catch {}
    }
    if (active?.type === 'activeSelection') {
      try {
        cloned.set?.({
          left: Number(source?.left ?? 0) + 24,
          top: Number(source?.top ?? 0) + 24,
        });
      } catch {}
    }
    return cloned;
  };

  const run = async () => {
    if (active.type === 'activeSelection' && typeof active.getObjects === 'function') {
      const objects = (active.getObjects?.() || []).filter((obj: any) => !!obj && !obj?._asukaRole && !String(obj?._asukaElementName ?? '').trim());
      if (!objects.length) return;
      const sourceObjectIds = objects.map((obj: any) => String(ensureObjectId(obj))).filter(Boolean);
      emitBefore('canvas.object.duplicate', { sourceObjectIds, batch: true }, { instanceId });
      const clones: any[] = [];
      for (let i = 0; i < objects.length; i += 1) {
        const cloned = prepareClone(objects[i], await cloneOne(objects[i]), i);
        if (!cloned) continue;
        (instance as any).addObject?.(cloned, { setActive: false, commitHistory: false, __skipPermissionCheck: true });
        clones.push(cloned);
      }
      if (!clones.length) return;
      try {
        const sel = new (fabric as any).ActiveSelection(clones, { canvas });
        canvas.setActiveObject?.(sel);
      } catch {
        try { canvas.setActiveObject?.(clones[clones.length - 1]); } catch {}
      }
      try { canvas.requestRenderAll?.(); } catch {}
      try { scheduleHistorySnapshot({ force: true }); } catch {}
      emitAfter('canvas.object.duplicate', { sourceObjectIds, objectIds: clones.map((c: any) => String(ensureObjectId(c))) , batch: true }, { instanceId });
      return;
    }

    if (typeof active.clone !== 'function') return;
    const sourceObjectId = ensureObjectId(active);
    emitBefore('canvas.object.duplicate', { sourceObjectId }, { instanceId });
    const cloned = prepareClone(active, await cloneOne(active), 0);
    if (!cloned) return;
    (instance as any).addObject?.(cloned, { setActive: true, commitHistory: false, __skipPermissionCheck: true });
    try { scheduleHistorySnapshot({ force: true }); } catch {}
    emitAfter('canvas.object.duplicate', { sourceObjectId, objectId: ensureObjectId(cloned) }, { instanceId });
  };

  void run();
},

onChange(listener: () => void) {
      changeListeners.add(listener);
      return () => {
        changeListeners.delete(listener);
      };
    },

    exportSnapshot() {
      try {
        syncDocumentObjectsForView();
        const state = useAsukaStore.getState() as any;
        const viewId = String(state?.activeViewId ?? '').trim();
        if (!viewId) return null;
        return cloneJson({ objects: (getDocumentObjectsState()[viewId] ?? []) });
      } catch {
        return null;
      }
    },

    importSnapshot(snapshot: DesignerSnapshot | null) {
      emitBefore('designer.importSnapshot', { designer: instance, snapshot });
      const state = useAsukaStore.getState() as any;
      const activeViewId = String(state?.activeViewId ?? '').trim();
      const fallback = initialSnapshot ? cloneJson(initialSnapshot) : null;
      const raw = snapshot ?? fallback;
      if (!raw) {
        return;
      }

      const normalized = raw?.objectsByView
        ? cloneJson(raw)
        : {
            activeViewId,
            objectsByView: {
              ...cloneJson(getDocumentObjectsState()),
              ...(activeViewId ? { [activeViewId]: cloneJson(Array.isArray((raw as any)?.objects) ? (raw as any).objects : []) } : {}),
            },
            selection: cloneJson(state?.documentSelection ?? { activeObjectIds: [], activeDrawingAreaId: null }),
            viewportByView: cloneJson(state?.documentViewportByView ?? {}),
          };

      const importHistoryViewId = getHistoryViewKey((normalized as any)?.activeViewId ?? activeViewId);
      const activeTrack = getHistoryTrack(importHistoryViewId);
      historyStack.splice(0, historyStack.length);
      historyStack.push(cloneJson(normalized));
      initialSnapshot = cloneJson(normalized);
      historyIndex = 0;
      try { lastHistorySignature = getHistorySnapshotSignature(normalized); } catch { try { lastHistorySignature = JSON.stringify(normalized); } catch { lastHistorySignature = ''; } }
      persistActiveHistoryVarsToTrack(activeTrack);

      void withHistoryMuted(async () => {
        loadDocumentSnapshot(cloneJson(normalized));
        try { lastHistorySignature = JSON.stringify(normalized); } catch {}
        persistActiveHistoryVarsToTrack(activeTrack);
        notifyChange();
      });
      emitAfter('designer.importSnapshot', { designer: instance, snapshot });
    },

    dispose() {
      emitBefore('designer.dispose', { designer: instance });
      try { unsubscribeFabricHistoryViewSwitch?.(); } catch {}
      unsubscribeFabricHistoryViewSwitch = null;
      try { unsubscribeFixedSlotPlacementInteraction?.(); } catch {}
      try { setFixedSlotPlacementInteractionMode(false); } catch {}
      try {
      window.removeEventListener('keydown', __onKeyDown as any);
      window.removeEventListener('keyup', __onKeyUp as any);
      document.removeEventListener(__nativeTransformReleaseEvent, __onNativeTransformRelease, true);
      document.removeEventListener('touchend', __onNativeTransformRelease, true);
      if (__nativeTransformReleaseTimer != null) window.clearTimeout(__nativeTransformReleaseTimer);
      __nativeTransformReleaseTimer = null;
    } catch {}

try { delete (canvas as any).__asukaDeleteActiveObject; } catch {}
      try { delete (canvas as any).__asukaDuplicateActiveObject; } catch {}

canvas.dispose();
      if (hostElement.contains(canvasElement)) {
        hostElement.removeChild(canvasElement);
      }
      emitAfter('designer.dispose', { designer: instance });
    },
  };


  rendererLifecycleRuntime = createRendererLifecycleRuntime({
    canvas,
    instance,
    useAsukaStore,
    sanitizeFabricControls,
    selectionOutlineRuntime,
    curvedGuideRuntime,
    snapLinesRuntime,
    clipPathsRuntime,
    resetDrawingZoneRect: () => { (drawingZoneRect as any) = null; },
    rebuildRuntimeObjectIndex,
    syncDrawingZoneVisuals,
    applyAsukaControlsDeep,
    debugDescribeControls,
    getControlsDebugEnabled: () => ASUKA_CONTROLS_DEBUG,
    isControlEligible: (o: any) => {
      if ((o as any)?._asukaElementIsInstance || (o as any)?._asukaElementName) return false;
      return String((o as any)?._asukaRole ?? 'user') === 'user';
    },
    importViewSnapshots,
    getSnapshotForView,
    getDocumentObjectsState,
    switchViewWithCanvas,
    syncDocumentViewportState,
    syncDocumentSelectionState,
    rebindSpecialObjectsImpl,
    fixedSlotsRuntime,
  });

  const documentActions = createDocumentActions({
    instance,
    addObject: (...args: any[]) => (instance as any).addObject?.(...args),
    updateObject: (...args: any[]) => (instance as any).updateObject?.(...args),
    removeObject: (...args: any[]) => (instance as any).removeObject?.(...args),
    readDocumentSnapshot,
    loadDocumentSnapshot,
    rebuildViewFromDocumentState,
    syncDocumentObjectsForView,
  });

  const rendererRuntime = createRendererRuntime({
    instance,
    syncDrawingZoneVisuals,
    clipPathsRuntime,
    snapLinesRuntime,
    curvedGuideRuntime,
    selectionOutlineRuntime,
    runtimeObjectIndex,
    fixedSlots: fixedSlotsRuntime,
    textEditing: textEditingRuntime,
    objectModified: objectModifiedRuntime,
    objectCollection: objectCollectionRuntime,
    drawingAreas: drawingAreasLifecycleRuntime,
    drawingAreasInteraction: drawingAreasInteractionRuntime,
    lifecycle: rendererLifecycleRuntime,
  });

  const documentNamespace = createDocumentNamespace({ actions: documentActions });
  const runtimeNamespace = createRuntimeNamespace({ runtime: rendererRuntime });

  (instance as any).documentActions = documentActions;
  (instance as any).document = documentNamespace;
  (instance as any).runtime = runtimeNamespace;

  // Internal hook used by helper modules (images/SVG) to make externally loaded objects
  // behave like native Asuka objects (custom controls + clip + active zone assignment).
  (instance as any)._asukaPrepareExternalObject = prepareExternalObject;
  // Internal hook used by store-only marker actions (edit/remove) so fixedSlots
  // changes are persisted in the same undo/redo stack as canvas object changes.
  try {
    const offBefore = onAsukaEvent('before:views.switch', (payload?: any) => {
      if (isStateRestoringNow()) return;
      const fromViewId = String(payload?.from ?? '').trim();
      if (!fromViewId) return;
      try {
        const activeKind = String((useAsukaStore.getState() as any)?.activeRenderEngineKind ?? 'fabric').toLowerCase();
        if (activeKind !== 'fabric') return;
      } catch {}
      try { syncDocumentObjectsForView(fromViewId); } catch {}
      try { persistActiveHistoryVarsToTrack(ensureHistoryTrack(DOCUMENT_HISTORY_VIEW_ID)); } catch {}
      try { persistFabricHistoryTrackToStore(DOCUMENT_HISTORY_VIEW_ID, 'before:views.switch'); } catch {}
    });
    const offAfter = onAsukaEvent('after:views.switch', (payload?: any) => {
      if (isStateRestoringNow()) return;
      const toViewId = String(payload?.to ?? '').trim();
      if (!toViewId) return;
      const engine = String(payload?.engine ?? (useAsukaStore.getState() as any)?.activeRenderEngineKind ?? 'fabric').toLowerCase();
      if (engine === '3d') return;
      try {
        const storeTrack = (useAsukaStore.getState() as any)?.documentFabricHistoryByView?.[DOCUMENT_HISTORY_VIEW_ID] ?? (useAsukaStore.getState() as any)?.documentFabricHistoryByView?.[toViewId] ?? null;
        const localTrack = exportFabricHistoryTrack(DOCUMENT_HISTORY_VIEW_ID);
        const storeLength = Array.isArray(storeTrack?.snapshots) ? storeTrack.snapshots.length : 0;
        const localLength = Array.isArray(localTrack?.snapshots) ? localTrack.snapshots.length : 0;
        const storeIndex = Number(storeTrack?.index ?? -1);
        const localIndex = Number(localTrack?.index ?? -1);
        asukaHistoryDebug('designer.fabricHistory.afterViewSwitch.compare', {
          toViewId,
          storeTrack: summarizeHistoryTrack(storeTrack),
          localTrack: summarizeHistoryTrack(localTrack),
          shouldImport: storeLength > 0 && (localLength === 0 || storeLength > localLength || storeIndex !== localIndex),
        });
        if (storeLength > 0 && (localLength === 0 || storeLength > localLength || storeIndex !== localIndex)) {
          importFabricHistoryTrack(DOCUMENT_HISTORY_VIEW_ID, storeTrack);
        }
      } catch (error) {
        asukaHistoryDebug('designer.fabricHistory.afterViewSwitch.error', { toViewId, error: String((error as any)?.message ?? error) });
      }
      scheduleHistoryInfoSyncForView(toViewId, 'view-switch-baseline');
    });
    unsubscribeFabricHistoryViewSwitch = () => {
      try { offBefore?.(); } catch {}
      try { offAfter?.(); } catch {}
    };
  } catch {}

  (instance as any).__asukaScheduleHistorySnapshot = scheduleHistorySnapshot;
  (instance as any).__asukaCommitDocumentHistorySnapshot = commitHistorySnapshotNow;
  (instance as any).__asukaReadDocumentHistorySnapshot = () => cloneJson(readDocumentSnapshot());
  (instance as any).__asukaCaptureHistoryBaseline = captureHistoryBaseline;
  (instance as any).exportHistoryTrack = exportFabricHistoryTrack;
  (instance as any).importHistoryTrack = importFabricHistoryTrack;
  (instance as any).__asukaPlacement = {
    getStatus: placementController.getStatus,
    begin: (input: any) => emitFabricPlacement('placement.begin', placementController.begin(input)),
    update: (input: any) => emitFabricPlacement('placement.update', placementController.update(input)),
    commit: (metadata?: Record<string, unknown>) => emitFabricPlacement('placement.commit', placementController.commit(metadata)),
    cancel: (metadata?: Record<string, unknown>) => emitFabricPlacement('placement.cancel', placementController.cancel(metadata)),
    clear: () => {
      placementController.clear();
      emitFabricPlacement('placement.clear', null);
    },
  };
  try { (canvas as any).__asukaDeleteActiveObject = () => (instance as any).deleteActiveObject?.(); } catch {}
  try { (canvas as any).__asukaDuplicateActiveObject = () => (instance as any).duplicateActiveObject?.(); } catch {}

  // Capture the initial "home" view once everything is ready.
  // This gives a stable reset behavior even after multiple zoom/pan operations.
  __captureHomeView();
  captureHistoryBaseline({ reason: 'designer.init' });

  emitAfter('designer.init', { designer: instance });

  return {
    instance,
  };

}
