Skip to content

solidworks_mcp.ui.services.preview_service

solidworks_mcp.ui.services.preview_service

Preview and feature-highlighting service for the Prefab CAD assistant dashboard.

Exports PNG screenshots and 3D geometry (GLB/STL) from the active SolidWorks model and persists preview state in the session.

Attributes

DEFAULT_API_ORIGIN module-attribute

DEFAULT_API_ORIGIN = getenv('SOLIDWORKS_UI_API_ORIGIN', 'http://127.0.0.1:8766')

DEFAULT_PREVIEW_ORIENTATION module-attribute

DEFAULT_PREVIEW_ORIENTATION = 'current'

Functions

_public_preview_url

_public_preview_url(preview_path: Path, *, api_origin: str = DEFAULT_API_ORIGIN) -> str

Build a cache-busting public URL for a preview image.

Parameters:

Name Type Description Default
preview_path Path

Filesystem path to the preview image.

required
api_origin str

Base URL of the running FastAPI server.

DEFAULT_API_ORIGIN

Returns:

Type Description
str

Public URL string with a ts query parameter.

Source code in src/solidworks_mcp/ui/services/preview_service.py
def _public_preview_url(
    preview_path: Path,
    *,
    api_origin: str = DEFAULT_API_ORIGIN,
) -> str:
    """Build a cache-busting public URL for a preview image.

    Args:
        preview_path: Filesystem path to the preview image.
        api_origin: Base URL of the running FastAPI server.

    Returns:
        Public URL string with a ``ts`` query parameter.
    """
    timestamp = (
        int(preview_path.stat().st_mtime) if preview_path.exists() else int(time.time())
    )
    return f"{api_origin}/previews/{preview_path.name}?ts={timestamp}"

_reopen_target_model_for_preview async

_reopen_target_model_for_preview(adapter: Any, model_path: str, *, context: str) -> None

Reopen the persisted target model before preview export.

Source code in src/solidworks_mcp/ui/services/preview_service.py
async def _reopen_target_model_for_preview(adapter: Any, model_path: str, *, context: str) -> None:
    """Reopen the persisted target model before preview export."""
    candidate_path = Path(str(model_path))
    if not candidate_path.exists():
        raise RuntimeError(f"Target model path for {context} does not exist: {candidate_path}")

    logger.info(
        "[ui.refresh_preview] reopening target model for {} {}",
        context,
        str(candidate_path.resolve()),
    )
    reopen_result = await adapter.open_model(str(candidate_path.resolve()))
    if not getattr(reopen_result, "is_success", False):
        raise RuntimeError(
            reopen_result.error
            or f"Failed to reopen target model for {context}: {candidate_path.resolve()}"
        )

create_adapter async

create_adapter(config: SolidWorksMCPConfig) -> SolidWorksAdapter

Async factory function for creating SolidWorks adapters.

Parameters:

Name Type Description Default
config SolidWorksMCPConfig

Configuration values for the operation.

required

Returns:

Name Type Description
SolidWorksAdapter SolidWorksAdapter

The result produced by the operation.

Source code in src/solidworks_mcp/adapters/factory.py
async def create_adapter(config: SolidWorksMCPConfig) -> SolidWorksAdapter:
    """Async factory function for creating SolidWorks adapters.

    Args:
        config (SolidWorksMCPConfig): Configuration values for the operation.

    Returns:
        SolidWorksAdapter: The result produced by the operation.
    """
    # Register adapters if not already done
    _register_default_adapters()

    # Create adapter using factory
    adapter = AdapterFactory.create_adapter(config)

    return adapter

ensure_preview_dir

ensure_preview_dir(preview_dir: Path | None = None) -> Path

Create and return the preview image directory.

Parameters:

Name Type Description Default
preview_dir Path | None

Override directory; defaults to .solidworks_mcp/ui_previews.

None

Returns:

Type Description
Path

Resolved Path that is guaranteed to exist.

Source code in src/solidworks_mcp/ui/services/_utils.py
def ensure_preview_dir(preview_dir: Path | None = None) -> Path:
    """Create and return the preview image directory.

    Args:
        preview_dir: Override directory; defaults to ``.solidworks_mcp/ui_previews``.

    Returns:
        Resolved ``Path`` that is guaranteed to exist.
    """
    resolved = preview_dir or _DEFAULT_PREVIEW_DIR
    resolved.mkdir(parents=True, exist_ok=True)
    return resolved

get_design_session

get_design_session(session_id: str, db_path: Path | None = None) -> dict[str, Any] | None

Return one session row as a dictionary.

Parameters:

Name Type Description Default
session_id str

The session id value.

required
db_path Path | None

The db path value. Defaults to None.

None

Returns:

Type Description
dict[str, Any] | None

dict[str, Any] | None: A dictionary containing the resulting values.

Source code in src/solidworks_mcp/agents/history_db.py
def get_design_session(
    session_id: str, db_path: Path | None = None
) -> dict[str, Any] | None:
    """Return one session row as a dictionary.

    Args:
        session_id (str): The session id value.
        db_path (Path | None): The db path value. Defaults to None.

    Returns:
        dict[str, Any] | None: A dictionary containing the resulting values.
    """
    resolved = init_db(db_path)
    engine = _build_engine(resolved)
    with Session(engine) as session:
        row = session.exec(
            select(DesignSession).where(DesignSession.session_id == session_id)
        ).first()

    if row is None:
        return None
    return {
        "session_id": row.session_id,
        "user_goal": row.user_goal,
        "source_mode": row.source_mode,
        "accepted_family": row.accepted_family,
        "status": row.status,
        "current_checkpoint_index": row.current_checkpoint_index,
        "metadata_json": row.metadata_json,
        "created_at": row.created_at,
        "updated_at": row.updated_at,
    }

highlight_feature async

highlight_feature(session_id: str, feature_name: str, *, db_path: Path | None = None, api_origin: str = DEFAULT_API_ORIGIN) -> dict[str, Any]

Select and highlight a named feature in the active SolidWorks model.

Parameters:

Name Type Description Default
session_id str

Dashboard session identifier.

required
feature_name str

Name of the feature to select (must match the feature-tree entry).

required
db_path Path | None

Optional SQLite path override.

None
api_origin str

Base URL of the running FastAPI server.

DEFAULT_API_ORIGIN

Returns:

Type Description
dict[str, Any]

Full dashboard state payload.

Source code in src/solidworks_mcp/ui/services/preview_service.py
async def highlight_feature(
    session_id: str,
    feature_name: str,
    *,
    db_path: Path | None = None,
    api_origin: str = DEFAULT_API_ORIGIN,
) -> dict[str, Any]:
    """Select and highlight a named feature in the active SolidWorks model.

    Args:
        session_id: Dashboard session identifier.
        feature_name: Name of the feature to select (must match the feature-tree entry).
        db_path: Optional SQLite path override.
        api_origin: Base URL of the running FastAPI server.

    Returns:
        Full dashboard state payload.
    """
    from .session_service import build_dashboard_state, ensure_dashboard_session  # noqa: PLC0415

    ensure_dashboard_session(session_id, db_path=db_path)
    session_row = get_design_session(session_id, db_path=db_path) or {}
    metadata = parse_json_blob(session_row.get("metadata_json"))
    active_model_path = metadata.get("active_model_path")
    resolved_name = (feature_name or "").strip()
    if not resolved_name:
        merge_metadata(
            session_id,
            db_path=db_path,
            latest_error_text="No feature name provided for selection.",
            remediation_hint="Pass a non-empty feature_name.",
        )
        return build_dashboard_state(session_id, db_path=db_path, api_origin=api_origin)

    try:
        known_feature_names: set[str] = set()
        for snapshot in list_model_state_snapshots(session_id, db_path=db_path):
            raw_tree = snapshot.get("feature_tree_json")
            if not raw_tree:
                continue
            try:
                parsed_tree = json.loads(raw_tree)
            except Exception:
                continue
            if isinstance(parsed_tree, list):
                known_feature_names.update(
                    str(item.get("name") or "").strip()
                    for item in parsed_tree
                    if str(item.get("name") or "").strip()
                )
                if known_feature_names:
                    break

        config = load_config()
        adapter = await create_adapter(config)
        await adapter.connect()
        if active_model_path and hasattr(adapter, "open_model"):
            candidate = Path(str(active_model_path))
            if candidate.exists():
                await adapter.open_model(str(candidate.resolve()))
        selected = False
        entity_type = ""
        selected_name = resolved_name
        if hasattr(adapter, "select_feature"):
            result = await adapter.select_feature(resolved_name)
            if result.is_success and isinstance(result.data, dict):
                selected = bool(result.data.get("selected"))
                entity_type = str(result.data.get("entity_type") or "")
                selected_name = str(result.data.get("selected_name") or resolved_name)
        await adapter.disconnect()
        tracked_only = (not selected) and (resolved_name in known_feature_names)
        merge_metadata(
            session_id,
            db_path=db_path,
            selected_feature_name=resolved_name,
            selected_feature_selector_name=selected_name,
            latest_message=(
                f"Selected '{resolved_name}' ({entity_type}) in SolidWorks."
                if selected
                else (
                    f"Tracking '{resolved_name}' from the feature tree. "
                    "SolidWorks did not expose a direct selectable handle for that row."
                    if tracked_only
                    else f"Could not select feature '{resolved_name}' — name may not match the feature tree."
                )
            ),
            latest_error_text=(
                ""
                if (selected or tracked_only)
                else f"SelectByID2 returned False for '{resolved_name}'."
            ),
            remediation_hint=(
                ""
                if (selected or tracked_only)
                else "Check that the feature name exactly matches the SolidWorks feature tree entry."
            ),
        )
        insert_tool_call_record(
            session_id=session_id,
            tool_name="ui.highlight_feature",
            input_json=json.dumps({"feature_name": resolved_name}, ensure_ascii=True),
            output_json=json.dumps(
                {
                    "selected": selected,
                    "tracked_only": tracked_only,
                    "entity_type": entity_type,
                },
                ensure_ascii=True,
            ),
            success=(selected or tracked_only),
            db_path=db_path,
        )
    except Exception as exc:
        logger.exception("[ui.highlight_feature] failed: {}", exc)
        merge_metadata(
            session_id,
            db_path=db_path,
            latest_error_text=str(exc),
            remediation_hint="Ensure SolidWorks is open with the target model loaded.",
        )
    return build_dashboard_state(session_id, db_path=db_path, api_origin=api_origin)

insert_model_state_snapshot

insert_model_state_snapshot(*, session_id: str, checkpoint_id: int | None = None, model_path: str | None = None, feature_tree_json: str | None = None, mass_properties_json: str | None = None, screenshot_path: str | None = None, state_fingerprint: str | None = None, db_path: Path | None = None) -> int

Insert model snapshot row and return snapshot ID for rollback tracking.

Parameters:

Name Type Description Default
session_id str

The session id value.

required
checkpoint_id int | None

The checkpoint id value. Defaults to None.

None
model_path str | None

The model path value. Defaults to None.

None
feature_tree_json str | None

The feature tree json value. Defaults to None.

None
mass_properties_json str | None

The mass properties json value. Defaults to None.

None
screenshot_path str | None

The screenshot path value. Defaults to None.

None
state_fingerprint str | None

The state fingerprint value. Defaults to None.

None
db_path Path | None

The db path value. Defaults to None.

None

Returns:

Name Type Description
int int

The computed numeric result.

Source code in src/solidworks_mcp/agents/history_db.py
def insert_model_state_snapshot(
    *,
    session_id: str,
    checkpoint_id: int | None = None,
    model_path: str | None = None,
    feature_tree_json: str | None = None,
    mass_properties_json: str | None = None,
    screenshot_path: str | None = None,
    state_fingerprint: str | None = None,
    db_path: Path | None = None,
) -> int:
    """Insert model snapshot row and return snapshot ID for rollback tracking.

    Args:
        session_id (str): The session id value.
        checkpoint_id (int | None): The checkpoint id value. Defaults to None.
        model_path (str | None): The model path value. Defaults to None.
        feature_tree_json (str | None): The feature tree json value. Defaults to None.
        mass_properties_json (str | None): The mass properties json value. Defaults to None.
        screenshot_path (str | None): The screenshot path value. Defaults to None.
        state_fingerprint (str | None): The state fingerprint value. Defaults to None.
        db_path (Path | None): The db path value. Defaults to None.

    Returns:
        int: The computed numeric result.
    """
    resolved = init_db(db_path)
    engine = _build_engine(resolved)
    with Session(engine) as session:
        row = ModelStateSnapshot(
            session_id=session_id,
            checkpoint_id=checkpoint_id,
            model_path=model_path,
            feature_tree_json=feature_tree_json,
            mass_properties_json=mass_properties_json,
            screenshot_path=screenshot_path,
            state_fingerprint=state_fingerprint,
            created_at=_utc_now_iso(),
        )
        session.add(row)
        session.commit()
        session.refresh(row)
        return int(row.id or 0)

insert_tool_call_record

insert_tool_call_record(*, session_id: str, tool_name: str, checkpoint_id: int | None = None, run_id: str | None = None, input_json: str | None = None, output_json: str | None = None, success: bool = True, latency_ms: float | None = None, db_path: Path | None = None) -> None

Insert one tool call execution record.

Parameters:

Name Type Description Default
session_id str

The session id value.

required
tool_name str

The tool name value.

required
checkpoint_id int | None

The checkpoint id value. Defaults to None.

None
run_id str | None

The run id value. Defaults to None.

None
input_json str | None

The input json value. Defaults to None.

None
output_json str | None

The output json value. Defaults to None.

None
success bool

The success value. Defaults to True.

True
latency_ms float | None

The latency ms value. Defaults to None.

None
db_path Path | None

The db path value. Defaults to None.

None

Returns:

Name Type Description
None None

None.

Source code in src/solidworks_mcp/agents/history_db.py
def insert_tool_call_record(
    *,
    session_id: str,
    tool_name: str,
    checkpoint_id: int | None = None,
    run_id: str | None = None,
    input_json: str | None = None,
    output_json: str | None = None,
    success: bool = True,
    latency_ms: float | None = None,
    db_path: Path | None = None,
) -> None:
    """Insert one tool call execution record.

    Args:
        session_id (str): The session id value.
        tool_name (str): The tool name value.
        checkpoint_id (int | None): The checkpoint id value. Defaults to None.
        run_id (str | None): The run id value. Defaults to None.
        input_json (str | None): The input json value. Defaults to None.
        output_json (str | None): The output json value. Defaults to None.
        success (bool): The success value. Defaults to True.
        latency_ms (float | None): The latency ms value. Defaults to None.
        db_path (Path | None): The db path value. Defaults to None.

    Returns:
        None: None.
    """
    resolved = init_db(db_path)
    engine = _build_engine(resolved)
    with Session(engine) as session:
        session.add(
            ToolCallRecord(
                session_id=session_id,
                checkpoint_id=checkpoint_id,
                run_id=run_id,
                tool_name=tool_name,
                input_json=input_json,
                output_json=output_json,
                success=success,
                latency_ms=latency_ms,
                created_at=_utc_now_iso(),
            )
        )
        session.commit()

list_model_state_snapshots

list_model_state_snapshots(session_id: str, db_path: Path | None = None) -> list[dict[str, Any]]

List snapshots for a session newest first for diff/rollback flows.

Parameters:

Name Type Description Default
session_id str

The session id value.

required
db_path Path | None

The db path value. Defaults to None.

None

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: A list containing the resulting items.

Source code in src/solidworks_mcp/agents/history_db.py
def list_model_state_snapshots(
    session_id: str,
    db_path: Path | None = None,
) -> list[dict[str, Any]]:
    """List snapshots for a session newest first for diff/rollback flows.

    Args:
        session_id (str): The session id value.
        db_path (Path | None): The db path value. Defaults to None.

    Returns:
        list[dict[str, Any]]: A list containing the resulting items.
    """
    resolved = init_db(db_path)
    engine = _build_engine(resolved)
    with Session(engine) as session:
        rows = session.exec(
            select(ModelStateSnapshot)
            .where(ModelStateSnapshot.session_id == session_id)
            .order_by(ModelStateSnapshot.id.desc())  # type: ignore[union-attr]
        ).all()

    return [
        {
            "id": row.id,
            "session_id": row.session_id,
            "checkpoint_id": row.checkpoint_id,
            "model_path": row.model_path,
            "feature_tree_json": row.feature_tree_json,
            "mass_properties_json": row.mass_properties_json,
            "screenshot_path": row.screenshot_path,
            "state_fingerprint": row.state_fingerprint,
            "created_at": row.created_at,
        }
        for row in rows
    ]

load_config

load_config(config_file: str | None = None) -> SolidWorksMCPConfig

Load configuration from file and environment variables.

Parameters:

Name Type Description Default
config_file str | None

The config file value. Defaults to None.

None

Returns:

Name Type Description
SolidWorksMCPConfig SolidWorksMCPConfig

The result produced by the operation.

Source code in src/solidworks_mcp/config.py
def load_config(config_file: str | None = None) -> SolidWorksMCPConfig:
    """Load configuration from file and environment variables.

    Args:
        config_file (str | None): The config file value. Defaults to None.

    Returns:
        SolidWorksMCPConfig: The result produced by the operation.
    """
    if config_file:
        config_path = Path(config_file)
        if config_path.exists() and config_path.suffix.lower() == ".json":
            import json

            with config_path.open("r", encoding="utf-8") as f:
                data = json.load(f)
            return SolidWorksMCPConfig(**data)
        return SolidWorksMCPConfig.from_env(str(config_path))

    return SolidWorksMCPConfig.from_env()

merge_metadata

merge_metadata(session_id: str, *, db_path: Path | None = None, user_goal: str | None = None, **updates: Any) -> dict[str, Any]

Read session metadata, merge updates into it, and write it back.

Implements the optimistic read-modify-write pattern used across all service functions that need to update one or more metadata keys without overwriting unrelated keys.

Parameters:

Name Type Description Default
session_id str

Target session identifier.

required
db_path Path | None

Optional override for the SQLite database path.

None
user_goal str | None

When provided, also updates the user_goal column.

None
**updates Any

Arbitrary key-value pairs to merge into metadata.

{}

Returns:

Type Description
dict[str, Any]

The merged metadata dict after the write.

Source code in src/solidworks_mcp/ui/services/_utils.py
def merge_metadata(
    session_id: str,
    *,
    db_path: Path | None = None,
    user_goal: str | None = None,
    **updates: Any,
) -> dict[str, Any]:
    """Read session metadata, merge *updates* into it, and write it back.

    Implements the optimistic read-modify-write pattern used across all
    service functions that need to update one or more metadata keys without
    overwriting unrelated keys.

    Args:
        session_id: Target session identifier.
        db_path: Optional override for the SQLite database path.
        user_goal: When provided, also updates the ``user_goal`` column.
        **updates: Arbitrary key-value pairs to merge into metadata.

    Returns:
        The merged metadata dict after the write.
    """
    session_row = get_design_session(session_id, db_path=db_path)
    metadata = parse_json_blob(session_row["metadata_json"]) if session_row else {}
    metadata.update(updates)

    effective_goal = user_goal or (
        session_row["user_goal"] if session_row else DEFAULT_USER_GOAL
    )
    effective_source = (
        session_row["source_mode"] if session_row else DEFAULT_SOURCE_MODE
    )
    effective_family = session_row["accepted_family"] if session_row else None
    effective_status = session_row["status"] if session_row else "active"
    effective_index = session_row["current_checkpoint_index"] if session_row else 0

    upsert_design_session(
        session_id=session_id,
        user_goal=effective_goal,
        source_mode=effective_source,
        accepted_family=effective_family,
        status=effective_status,
        current_checkpoint_index=effective_index,
        metadata_json=json.dumps(metadata, ensure_ascii=True),
        db_path=db_path,
    )
    return metadata

parse_json_blob

parse_json_blob(payload: str | None) -> dict[str, Any]

Parse a JSON string into a dict, returning an empty dict on any failure.

Parameters:

Name Type Description Default
payload str | None

Raw JSON string, or None.

required

Returns:

Type Description
dict[str, Any]

Parsed dict, or {} if parsing fails.

Source code in src/solidworks_mcp/ui/services/_utils.py
def parse_json_blob(payload: str | None) -> dict[str, Any]:
    """Parse a JSON string into a dict, returning an empty dict on any failure.

    Args:
        payload: Raw JSON string, or ``None``.

    Returns:
        Parsed dict, or ``{}`` if parsing fails.
    """
    if not payload:
        return {}
    try:
        parsed = json.loads(payload)
    except json.JSONDecodeError:
        return {}
    return parsed if isinstance(parsed, dict) else {}

refresh_preview async

refresh_preview(session_id: str, *, orientation: str = DEFAULT_PREVIEW_ORIENTATION, db_path: Path | None = None, preview_dir: Path | None = None, api_origin: str = DEFAULT_API_ORIGIN, adapter_override: Any | None = None, active_model_path_override: str | None = None, reopen_active_model: bool = True) -> dict[str, Any]

Export the current SolidWorks viewport to a PNG preview and GLB/STL for the 3D viewer.

Parameters:

Name Type Description Default
session_id str

Dashboard session identifier.

required
orientation str

View orientation for the PNG export ("front", "top", "right", "isometric", "current").

DEFAULT_PREVIEW_ORIENTATION
db_path Path | None

Optional SQLite path override.

None
preview_dir Path | None

Override for the preview output directory.

None
api_origin str

Base URL of the running FastAPI server.

DEFAULT_API_ORIGIN
adapter_override Any | None

Pre-connected adapter (avoids creating a second connection when called from connect_target_model).

None
active_model_path_override str | None

Override for the model path to reopen.

None
reopen_active_model bool

When True, reopen the persisted active model path before exporting (default).

True

Returns:

Type Description
dict[str, Any]

Full dashboard state payload.

Source code in src/solidworks_mcp/ui/services/preview_service.py
async def refresh_preview(
    session_id: str,
    *,
    orientation: str = DEFAULT_PREVIEW_ORIENTATION,
    db_path: Path | None = None,
    preview_dir: Path | None = None,
    api_origin: str = DEFAULT_API_ORIGIN,
    adapter_override: Any | None = None,
    active_model_path_override: str | None = None,
    reopen_active_model: bool = True,
) -> dict[str, Any]:
    """Export the current SolidWorks viewport to a PNG preview and GLB/STL for the 3D viewer.

    Args:
        session_id: Dashboard session identifier.
        orientation: View orientation for the PNG export
            (``"front"``, ``"top"``, ``"right"``, ``"isometric"``, ``"current"``).
        db_path: Optional SQLite path override.
        preview_dir: Override for the preview output directory.
        api_origin: Base URL of the running FastAPI server.
        adapter_override: Pre-connected adapter (avoids creating a second connection when
            called from ``connect_target_model``).
        active_model_path_override: Override for the model path to reopen.
        reopen_active_model: When ``True``, reopen the persisted active model path before
            exporting (default).

    Returns:
        Full dashboard state payload.
    """
    from .session_service import build_dashboard_state, ensure_dashboard_session  # noqa: PLC0415

    ensure_dashboard_session(session_id, db_path=db_path)
    logger.info(
        "[ui.refresh_preview] session_id={} orientation={}",
        session_id,
        orientation,
    )
    session_row = get_design_session(session_id, db_path=db_path) or {}
    metadata = parse_json_blob(session_row.get("metadata_json"))
    preview_viewer_url = sanitize_preview_viewer_url(
        metadata.get("preview_viewer_url"),
        session_id=session_id,
        api_origin=api_origin,
    )
    resolved_preview_dir = ensure_preview_dir(preview_dir)
    preview_path = resolved_preview_dir / f"{session_id}.png"
    active_model_path = active_model_path_override or metadata.get("active_model_path")
    adapter = adapter_override
    owns_adapter = adapter_override is None

    try:
        if not active_model_path:
            raise RuntimeError(
                "No attached model path found for preview refresh. Attach a target model first."
            )

        if adapter is None:
            config = load_config()
            adapter = await create_adapter(config)
        if owns_adapter:
            await adapter.connect()
        if reopen_active_model and hasattr(adapter, "open_model"):
            await _reopen_target_model_for_preview(
                adapter, str(active_model_path), context="preview"
            )

        # --- Step 1: Export 3D geometry for the Three.js viewer (GLB preferred) ---
        glb_path = resolved_preview_dir / f"{session_id}.glb"
        stl_path = resolved_preview_dir / f"{session_id}.stl"
        viewer_ts = int(time.time())
        viewer_format = "none"
        try:
            glb_result = await adapter.export_file(str(glb_path.resolve()), "glb")
            if (
                glb_result.is_success
                and glb_path.exists()
                and glb_path.stat().st_size > 0
            ):
                viewer_format = "glb"
                viewer_ts = int(glb_path.stat().st_mtime)
                logger.info(
                    "[ui.refresh_preview] GLB export succeeded path={}",
                    str(glb_path.resolve()),
                )
        except Exception as _glb_exc:
            logger.warning("[ui.refresh_preview] GLB export failed: {}", str(_glb_exc))

        if viewer_format == "none":
            try:
                stl_result = await adapter.export_file(str(stl_path.resolve()), "stl")
                if (
                    stl_result.is_success
                    and stl_path.exists()
                    and stl_path.stat().st_size > 0
                ):
                    viewer_format = "stl"
                    viewer_ts = int(stl_path.stat().st_mtime)
                    logger.info(
                        "[ui.refresh_preview] STL export succeeded path={}",
                        str(stl_path.resolve()),
                    )
            except Exception:
                logger.debug("[ui.refresh_preview] STL export skipped (adapter error)")

        preview_viewer_url = (
            f"{api_origin}/api/ui/viewer/{session_id}?t={viewer_ts}&fmt={viewer_format}"
        )

        # --- Step 2: Export PNG screenshot (best-effort) ---
        png_payload = {
            "file_path": str(preview_path.resolve()),
            "format_type": "png",
            "width": 1280,
            "height": 720,
            "view_orientation": orientation,
        }
        png_ok = False
        png_error: str = ""
        snapshot_id: int | None = None
        try:
            result = await adapter.export_image(png_payload)
            if result.is_success and preview_path.exists():
                png_ok = True
                logger.info(
                    "[ui.refresh_preview] PNG export succeeded file_path={}",
                    str(preview_path.resolve()),
                )
                snapshot_id = insert_model_state_snapshot(
                    session_id=session_id,
                    screenshot_path=str(preview_path.resolve()),
                    state_fingerprint=f"preview-{preview_path.stat().st_mtime_ns}",
                    db_path=db_path,
                )
                insert_tool_call_record(
                    session_id=session_id,
                    tool_name="export_image",
                    input_json=json.dumps(png_payload, ensure_ascii=True),
                    output_json=json.dumps(result.data or {}, ensure_ascii=True),
                    success=True,
                    db_path=db_path,
                )
            else:
                png_error = result.error or "export_image returned failure"
                logger.warning("[ui.refresh_preview] PNG export failed: {}", png_error)
        except Exception as png_exc:
            png_error = str(png_exc)
            logger.warning("[ui.refresh_preview] PNG export exception: {}", png_exc)

        if owns_adapter:
            await adapter.disconnect()

        # --- Step 3: Export per-orientation PNG thumbnails ---
        VIEW_ORIENTATIONS = ["isometric", "front", "top", "right"]
        preview_view_urls: dict[str, str] = {}
        try:
            config2 = load_config()
            adapter2 = await create_adapter(config2)
            await adapter2.connect()
            if hasattr(adapter2, "open_model"):
                await _reopen_target_model_for_preview(
                    adapter2,
                    str(active_model_path),
                    context="orientation previews",
                )
            _sel_name = str(
                metadata.get("selected_feature_selector_name")
                or metadata.get("selected_feature_name")
                or ""
            ).strip()
            if _sel_name and hasattr(adapter2, "select_feature"):
                try:
                    await adapter2.select_feature(_sel_name)
                    logger.info(
                        "[ui.refresh_preview] re-selected '{}' before view screenshots",
                        _sel_name,
                    )
                except Exception as _sel_exc:
                    logger.debug(
                        "[ui.refresh_preview] re-select '{}' failed (non-fatal): {}",
                        _sel_name,
                        _sel_exc,
                    )
            for view_name in VIEW_ORIENTATIONS:
                view_path = resolved_preview_dir / f"{session_id}-{view_name}.png"
                try:
                    if _sel_name and hasattr(adapter2, "select_feature"):
                        await adapter2.select_feature(_sel_name)
                    view_result = await adapter2.export_image(
                        {
                            "file_path": str(view_path.resolve()),
                            "format_type": "png",
                            "width": 640,
                            "height": 480,
                            "view_orientation": view_name,
                        }
                    )
                    if view_result.is_success and view_path.exists():
                        ts = int(view_path.stat().st_mtime)
                        preview_view_urls[view_name] = (
                            f"{api_origin}/previews/{view_path.name}?ts={ts}"
                        )
                        logger.info(
                            "[ui.refresh_preview] view PNG {} exported", view_name
                        )
                    else:
                        logger.warning(
                            "[ui.refresh_preview] view PNG {} failed: {}",
                            view_name,
                            view_result.error or "no detail",
                        )
                except Exception as _ve:
                    logger.warning(
                        "[ui.refresh_preview] view PNG {} exception: {}",
                        view_name,
                        str(_ve),
                    )
            await adapter2.disconnect()
        except Exception as _views_exc:
            logger.warning(
                "[ui.refresh_preview] multi-view export failed: {}", str(_views_exc)
            )

        # Preserve existing view URLs when a refresh attempt returns no images.
        existing_view_urls = metadata.get("preview_view_urls")
        if isinstance(existing_view_urls, dict):
            if not preview_view_urls:
                preview_view_urls = dict(existing_view_urls)
            else:
                merged_view_urls = dict(existing_view_urls)
                merged_view_urls.update(preview_view_urls)
                preview_view_urls = merged_view_urls

        viewer_label = (
            f"3D viewer ({viewer_format.upper()})"
            if viewer_format != "none"
            else "3D viewer (no model)"
        )
        png_label = "PNG" if png_ok else f"no PNG ({png_error})"
        status_msg = f"Preview refreshed ({viewer_label}, {png_label})."

        merge_metadata(
            session_id,
            db_path=db_path,
            preview_orientation=orientation,
            latest_message=status_msg,
            preview_status=status_msg,
            latest_snapshot_id=(str(snapshot_id) if snapshot_id is not None else ""),
            preview_viewer_url=preview_viewer_url,
            preview_stl_ready=(viewer_format != "none"),
            preview_png_ready=png_ok,
            preview_view_urls=preview_view_urls,
            latest_error_text="",
            remediation_hint="",
        )
    except Exception as exc:
        logger.exception("[ui.refresh_preview] failed: {}", exc)
        insert_tool_call_record(
            session_id=session_id,
            tool_name="export_image",
            input_json=json.dumps({"orientation": orientation}, ensure_ascii=True),
            output_json=json.dumps({"error": str(exc)}, ensure_ascii=True),
            success=False,
            db_path=db_path,
        )
        merge_metadata(
            session_id,
            db_path=db_path,
            latest_message=f"Preview refresh failed: {exc}",
            preview_status=f"Preview refresh failed: {exc}",
            preview_orientation=orientation,
            latest_error_text=str(exc),
            remediation_hint="Open a model in SolidWorks and retry preview refresh.",
        )

    return build_dashboard_state(session_id, db_path=db_path, api_origin=api_origin)

sanitize_preview_viewer_url

sanitize_preview_viewer_url(value: Any, *, session_id: str, api_origin: str) -> str

Validate and return a preview viewer URL, or "" if it looks wrong.

Rejects URLs pointing at unexpected origins or session IDs to prevent open-redirect-style issues in the embedded viewer iframe.

Parameters:

Name Type Description Default
value Any

Raw URL string from session metadata.

required
session_id str

Expected session ID segment in the path.

required
api_origin str

Allowed API origin (scheme + host + port).

required

Returns:

Type Description
str

Validated URL string, or "" if validation fails.

Source code in src/solidworks_mcp/ui/services/_utils.py
def sanitize_preview_viewer_url(
    value: Any,
    *,
    session_id: str,
    api_origin: str,
) -> str:
    """Validate and return a preview viewer URL, or ``""`` if it looks wrong.

    Rejects URLs pointing at unexpected origins or session IDs to prevent
    open-redirect-style issues in the embedded viewer iframe.

    Args:
        value: Raw URL string from session metadata.
        session_id: Expected session ID segment in the path.
        api_origin: Allowed API origin (scheme + host + port).

    Returns:
        Validated URL string, or ``""`` if validation fails.
    """
    text = sanitize_ui_text(value, "")
    if not text:
        return ""
    parsed = urlparse(text)
    path = (parsed.path or "").rstrip("/")
    expected_path = f"/api/ui/viewer/{session_id}".rstrip("/")
    if path != expected_path:
        return ""
    if parsed.scheme and parsed.netloc:
        expected = urlparse(api_origin)
        if parsed.netloc != expected.netloc:
            return ""
    return text