diff --git a/docs/phase3-versioned-mesh-artifact.md b/docs/phase3-versioned-mesh-artifact.md new file mode 100644 index 000000000..d8f674a55 --- /dev/null +++ b/docs/phase3-versioned-mesh-artifact.md @@ -0,0 +1,45 @@ +# Phase 3 — Versioned mesh artifact (design) + +Status: scaffolded hooks only in the viewer/embed PRs. Full pipeline is a follow-up. + +## Goal + +Evaluate CAD once → persist a versioned build artifact (mesh + preview + diagnostics/bounds) → Studio viewer / embed loads the mesh and does **not** recompile CAD. + +## Cache identity + +Hash of: + +- source body +- runtime params +- referenced assets +- kernel / OCCT version +- meshing settings + +Deduplicate simultaneous same-build requests (single-flight). **Do not** remove the OCCT per-process mutex. + +## Artifact contents + +- triangle mesh (positions / indices / normals, per feature if needed) +- preview image (optional) +- diagnostics + bbox / camera-fit bounds +- build metadata (kernel version, mesher settings, source hash) + +## Delivery + +1. `open_in_studio` / project save enqueues or inline-builds artifact when cheap. +2. Embed accepts `?meshUrl=` (already wired as a search-param hook). +3. FunnelViewer accepts `meshUrl` prop (hook present; still executes source today). +4. Widget iframe prefers `embedUrl` which can later point at mesh-backed embed. + +## Non-goals for the first artifact PR + +- Removing code-driven embed path (keep as fallback when artifact missing) +- Touching OCCT poison / mutex recovery + +## Next implementation slice + +1. Server: `build_artifacts` table + object storage for mesh blobs. +2. Single-flight builder keyed by cache identity. +3. Embed: if `meshUrl` (or project artifact pointer) present, load mesh into Viewer without `executeCode`. +4. Keep source path as fallback when artifact miss / stale kernel. diff --git a/src/funnel/components/FunnelViewer.tsx b/src/funnel/components/FunnelViewer.tsx index 2e427223f..68199c606 100644 --- a/src/funnel/components/FunnelViewer.tsx +++ b/src/funnel/components/FunnelViewer.tsx @@ -8,24 +8,98 @@ * Integration pattern: WorkbenchProvider accepts `initialCode`; GeometryProvider * auto-executes whenever `code` changes; inner component reads geometry from * context and feeds Viewer with the same props Viewport.tsx uses. + * + * Embed hosts get explicit build/display status so an empty canvas is never + * presented as "ready" (iframe load alone is not enough). */ +import { useCallback, useEffect, useMemo, useState } from 'react'; import Viewer from '../../studio/components/Viewer'; +import { hasNonemptyGeometry } from '../../studio/components/viewer/hasNonemptyGeometry'; import { WorkbenchProvider, useWorkbench } from '../../studio/context/WorkbenchContext'; +export type FunnelViewerPhase = + | 'building_geometry' + | 'loading_mesh' + | 'model_displayed' + | 'build_failed' + | 'viewer_failed'; + export interface FunnelViewerProps { code: string; + /** Optional precomputed mesh artifact URL (Phase 3 hook). When set, hosts may + * skip re-executing source once the artifact pipeline lands. */ + meshUrl?: string | null; + onPhaseChange?: (phase: FunnelViewerPhase, detail?: string | null) => void; + /** Bump to remount the provider stack (Retry). */ + resetKey?: number | string; } /** Inner component — must be mounted inside WorkbenchProvider. */ -function FunnelViewerInner() { +function FunnelViewerInner({ + onPhaseChange, +}: { + onPhaseChange?: (phase: FunnelViewerPhase, detail?: string | null) => void; +}) { const { geometries, previewGeometries, sketchesGeometries, showSketches, viewMode3D, + isReady, + isComputing, + error, } = useWorkbench(); + const [displayReady, setDisplayReady] = useState(false); + const [viewerError, setViewerError] = useState(null); + const [emptyBuildError, setEmptyBuildError] = useState(null); + + const nonempty = useMemo(() => hasNonemptyGeometry(geometries), [geometries]); + + const phase: FunnelViewerPhase = useMemo(() => { + if (viewerError) return 'viewer_failed'; + if (error || emptyBuildError) return 'build_failed'; + if (displayReady && nonempty) return 'model_displayed'; + if (!isReady || (isComputing && !nonempty)) return 'building_geometry'; + if (isComputing || (nonempty && !displayReady)) return 'loading_mesh'; + if (nonempty) return 'loading_mesh'; + return 'building_geometry'; + }, [viewerError, error, emptyBuildError, displayReady, nonempty, isReady, isComputing]); + + const detail = viewerError ?? emptyBuildError ?? error ?? null; + + useEffect(() => { + onPhaseChange?.(phase, detail); + }, [phase, detail, onPhaseChange]); + + // Empty successful build (no solid) is a build failure, not a blank "ready" canvas. + useEffect(() => { + if (!isComputing && isReady && !error && !nonempty && !viewerError) { + // Give the auto-run a beat to populate; if still empty after settle, surface failure. + const t = window.setTimeout(() => { + if (!hasNonemptyGeometry(geometries) && !error) { + setEmptyBuildError('Build produced no displayable geometry.'); + } + }, 800); + return () => window.clearTimeout(t); + } + return undefined; + }, [isComputing, isReady, error, nonempty, geometries, viewerError]); + + const onDisplayReady = useCallback(() => { + setDisplayReady(true); + setViewerError(null); + setEmptyBuildError(null); + }, []); + + const statusLabel = + phase === 'building_geometry' ? 'Building geometry…' + : phase === 'loading_mesh' ? 'Loading mesh…' + : phase === 'build_failed' ? `Build failed: ${detail ?? 'unknown error'}` + : phase === 'viewer_failed' ? `Viewer failed: ${detail ?? 'unknown error'}` + : null; + return (
+ {statusLabel ? ( +
+

{statusLabel}

+
+ ) : null}
); } @@ -43,11 +128,15 @@ function FunnelViewerInner() { * Mount this component with a `code` string — it spins up the provider stack, * executes the geometry, and renders the 3D canvas. No Studio chrome is pulled in. */ -export function FunnelViewer({ code }: FunnelViewerProps) { +export function FunnelViewer({ code, meshUrl, onPhaseChange, resetKey = 0 }: FunnelViewerProps) { + // meshUrl is a Phase 3 hook: when artifact pipeline exists, FunnelViewer (or a + // sibling mesh loader) can short-circuit CAD re-exec. Today we still execute code. + void meshUrl; + return ( -
- - +
+ +
); diff --git a/src/studio/components/Viewer.tsx b/src/studio/components/Viewer.tsx index 272a59f0f..bbeeeb4b2 100644 --- a/src/studio/components/Viewer.tsx +++ b/src/studio/components/Viewer.tsx @@ -11,6 +11,7 @@ import { useShellStore } from "../store/useShellStore"; // Extracted Components import { ViewerScene } from "./viewer/ViewerScene"; +import { DisplayReadySensor } from "./viewer/DisplayReadySensor"; import { ViewGizmo } from "./viewer/overlays/ViewGizmo"; // Extracted hooks @@ -28,9 +29,11 @@ interface ViewerProps { sketchesGeometries: SketchGeometry[]; showSketches: boolean; viewMode3D: ViewMode3D; + /** Embed/status hosts: fired once after nonempty geometry + camera fit + first frame. */ + onDisplayReady?: () => void; } -export default function Viewer({ geometries, previewGeometries, sketchesGeometries, showSketches, viewMode3D }: ViewerProps) { +export default function Viewer({ geometries, previewGeometries, sketchesGeometries, showSketches, viewMode3D, onDisplayReady }: ViewerProps) { const { setSelectedFace, selectedSketchName, @@ -153,6 +156,9 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri viewportBackground={viewportBackground} planes={planes} /> + {onDisplayReady ? ( + + ) : null} setNavigationRequest((prev) => ({ diff --git a/src/studio/components/viewer/DisplayReadySensor.test.ts b/src/studio/components/viewer/DisplayReadySensor.test.ts new file mode 100644 index 000000000..9369e290e --- /dev/null +++ b/src/studio/components/viewer/DisplayReadySensor.test.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import { describe, expect, it } from 'vitest'; +import { hasNonemptyGeometry } from './hasNonemptyGeometry'; +import type { GeometryResult } from '../../../shared/worker/geometryEngine'; + +function geom(face: { vertices: number; indices: number }): GeometryResult { + return { + faces: [ + { + faceId: 1, + vertices: new Float32Array(face.vertices), + indices: new Uint32Array(face.indices), + normals: new Float32Array(face.vertices), + }, + ], + }; +} + +describe('hasNonemptyGeometry', () => { + it('is false for empty or degenerate meshes', () => { + expect(hasNonemptyGeometry([])).toBe(false); + expect(hasNonemptyGeometry([geom({ vertices: 0, indices: 0 })])).toBe(false); + expect(hasNonemptyGeometry([geom({ vertices: 9, indices: 2 })])).toBe(false); + }); + + it('is true when a face has a triangle', () => { + expect(hasNonemptyGeometry([geom({ vertices: 9, indices: 3 })])).toBe(true); + }); +}); diff --git a/src/studio/components/viewer/DisplayReadySensor.tsx b/src/studio/components/viewer/DisplayReadySensor.tsx new file mode 100644 index 000000000..d00803395 --- /dev/null +++ b/src/studio/components/viewer/DisplayReadySensor.tsx @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import { useFrame } from '@react-three/fiber'; +import { useEffect, useRef } from 'react'; +import type { GeometryResult } from '../../../shared/worker/geometryEngine'; +import { hasNonemptyGeometry } from './hasNonemptyGeometry'; + +/** + * Fires `onDisplayReady` once after nonempty geometry is present and at least + * two animation frames have run (camera fit + first submitted frame). + * iframe `load` alone is not enough for embeds. + */ +export function DisplayReadySensor({ + geometries, + onDisplayReady, +}: { + geometries: GeometryResult[]; + onDisplayReady?: () => void; +}) { + const firedRef = useRef(false); + const framesWithGeomRef = useRef(0); + const onReadyRef = useRef(onDisplayReady); + + useEffect(() => { + onReadyRef.current = onDisplayReady; + }); + + useEffect(() => { + firedRef.current = false; + framesWithGeomRef.current = 0; + }, [geometries]); + + useFrame(() => { + if (!onReadyRef.current || firedRef.current) return; + if (!hasNonemptyGeometry(geometries)) { + framesWithGeomRef.current = 0; + return; + } + framesWithGeomRef.current += 1; + // Frame 1: CameraHandler schedules immediate fit. Frame 2+: fit applied + drawn. + if (framesWithGeomRef.current < 2) return; + firedRef.current = true; + onReadyRef.current(); + }); + + return null; +} diff --git a/src/studio/components/viewer/hasNonemptyGeometry.ts b/src/studio/components/viewer/hasNonemptyGeometry.ts new file mode 100644 index 000000000..eade1261f --- /dev/null +++ b/src/studio/components/viewer/hasNonemptyGeometry.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import type { GeometryResult } from '../../../shared/worker/geometryEngine'; + +/** True when at least one face has triangle indices (non-empty mesh). */ +export function hasNonemptyGeometry(geometries: GeometryResult[]): boolean { + return geometries.some((g) => + g.faces.some((f) => f.indices.length >= 3 && f.vertices.length >= 9), + ); +} diff --git a/src/studio/routes/embed.$slug.tsx b/src/studio/routes/embed.$slug.tsx index 475f391c7..ce842117a 100644 --- a/src/studio/routes/embed.$slug.tsx +++ b/src/studio/routes/embed.$slug.tsx @@ -15,10 +15,14 @@ * Models load anonymously by slug (capability-based): `fetchProjectBySlug` * returns public/`public_unlisted` rows with no auth. Private models resolve to * null → "Not available". + * + * Ready means the model is *displayed* (nonempty geometry + camera fitted + + * first frame), not merely that source finished downloading. iframe `load` is + * not enough. */ import { createFileRoute } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { FunnelViewer } from '../../funnel/components/FunnelViewer'; +import { useCallback, useEffect, useState } from 'react'; +import { FunnelViewer, type FunnelViewerPhase } from '../../funnel/components/FunnelViewer'; import { fetchProjectBySlug, fetchProjectRevisionBySlug } from '../../funnel/lib/apiClient'; import StudioApp from '../App'; import { StudioConfigProvider } from '../config/StudioConfigContext'; @@ -28,22 +32,39 @@ export const Route = createFileRoute('/embed/$slug')({ validateSearch: (search: Record) => ({ mode: embedPresentationMode(search.mode), revision: embedRevision(search.revision), + /** Phase 3 hook: optional versioned mesh artifact URL when present. */ + meshUrl: typeof search.meshUrl === 'string' && search.meshUrl.startsWith('https://') + ? search.meshUrl + : undefined, }), component: EmbedPage, }); +type EmbedUiPhase = + | 'loading_source' + | 'project_saved' + | 'building_geometry' + | 'loading_mesh' + | 'model_displayed' + | 'missing' + | 'build_failed' + | 'viewer_failed' + | 'source_error'; + function EmbedPage() { const { slug } = Route.useParams(); - const { mode, revision } = Route.useSearch(); + const { mode, revision, meshUrl } = Route.useSearch(); const sourceKey = `${slug}\u0000${revision === undefined ? 'current' : revision === null ? 'invalid' : revision}`; const [code, setCode] = useState(null); const [loadedSourceKey, setLoadedSourceKey] = useState(null); - const [state, setState] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading'); + const [sourceState, setSourceState] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading'); const [err, setErr] = useState(null); + const [viewerPhase, setViewerPhase] = useState(null); + const [viewerDetail, setViewerDetail] = useState(null); + const [retryKey, setRetryKey] = useState(0); useEffect(() => { - // `state` starts at 'loading' (initial useState); the fetch resolves it to - // ready/missing/error. No synchronous setState in the effect body. + // `sourceState` starts at 'loading'; the fetch resolves it. No sync setState in body. let disposed = false; const source = loadEmbedCode(revision, { loadCurrent: () => fetchProjectBySlug(slug).then((project) => project?.current_code ?? null), @@ -52,24 +73,71 @@ function EmbedPage() { source .then((sourceCode) => { if (disposed) return; - if (sourceCode) { setCode(sourceCode); setLoadedSourceKey(sourceKey); setState('ready'); } - else { setLoadedSourceKey(sourceKey); setState('missing'); } + if (sourceCode) { + setCode(sourceCode); + setLoadedSourceKey(sourceKey); + setSourceState('ready'); + } else { + setLoadedSourceKey(sourceKey); + setSourceState('missing'); + } }) .catch((e) => { if (disposed) return; // Requested release revisions fail closed: never substitute the live model // when the revision endpoint is unavailable or refuses access. - if (revision !== undefined) { setLoadedSourceKey(sourceKey); setState('missing'); return; } + if (revision !== undefined) { + setLoadedSourceKey(sourceKey); + setSourceState('missing'); + return; + } setLoadedSourceKey(sourceKey); setErr(String(e)); - setState('error'); + setSourceState('error'); }); return () => { disposed = true; }; - }, [slug, revision, sourceKey]); + }, [slug, revision, sourceKey, retryKey]); + + const onPhaseChange = useCallback((phase: FunnelViewerPhase, detail?: string | null) => { + setViewerPhase(phase); + setViewerDetail(detail ?? null); + }, []); const sourceSettled = loadedSourceKey === sourceKey; - if (revision !== null && sourceSettled && state === 'ready' && code) { + const uiPhase: EmbedUiPhase = (() => { + if (revision === null) return 'missing'; + if (!sourceSettled || sourceState === 'loading') return 'loading_source'; + if (sourceState === 'missing') return 'missing'; + if (sourceState === 'error') return 'source_error'; + // Source ready — project is fetched/persisted; viewer owns display readiness. + if (!viewerPhase) return 'project_saved'; + if (viewerPhase === 'building_geometry') return 'building_geometry'; + if (viewerPhase === 'loading_mesh') return 'loading_mesh'; + if (viewerPhase === 'model_displayed') return 'model_displayed'; + if (viewerPhase === 'build_failed') return 'build_failed'; + if (viewerPhase === 'viewer_failed') return 'viewer_failed'; + return 'project_saved'; + })(); + + const statusMessage = (() => { + switch (uiPhase) { + case 'loading_source': return 'Loading…'; + case 'project_saved': return 'Project saved. Building geometry…'; + case 'building_geometry': return 'Building geometry…'; + case 'loading_mesh': return 'Loading mesh…'; + case 'model_displayed': return null; + case 'missing': return 'Model not available.'; + case 'source_error': return `Failed to load: ${err}`; + case 'build_failed': return `Build failed: ${viewerDetail ?? 'unknown error'}`; + case 'viewer_failed': return `Viewer failed: ${viewerDetail ?? 'unknown error'}`; + default: return 'Loading…'; + } + })(); + + const canRetry = uiPhase === 'build_failed' || uiPhase === 'viewer_failed' || uiPhase === 'source_error'; + + if (revision !== null && sourceSettled && sourceState === 'ready' && code) { if (mode === 'studio') { return ( @@ -78,20 +146,61 @@ function EmbedPage() { ); } return ( -
- +
+ + {statusMessage ? ( +
+

+ {statusMessage} +

+ {canRetry ? ( + + ) : null} +
+ ) : null}
); } - const message = - revision === null || (sourceSettled && state === 'missing') ? 'Model not available.' - : !sourceSettled ? 'Loading…' - : state === 'error' ? `Failed to load: ${err}` - : 'Loading…'; return ( -
-

{message}

+
+
+

{statusMessage ?? 'Loading…'}

+ {canRetry ? ( + + ) : null} +
); }