Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/phase3-versioned-mesh-artifact.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 94 additions & 5 deletions src/funnel/components/FunnelViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [emptyBuildError, setEmptyBuildError] = useState<string | null>(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 (
<div className="absolute inset-0">
<Viewer
Expand All @@ -34,7 +108,18 @@ function FunnelViewerInner() {
sketchesGeometries={sketchesGeometries ?? []}
showSketches={showSketches ?? false}
viewMode3D={viewMode3D}
onDisplayReady={onDisplayReady}
/>
{statusLabel ? (
<div
className="absolute inset-0 grid place-items-center bg-code-bg/80 pointer-events-none"
data-testid="funnel-viewer-status"
role="status"
aria-live="polite"
>
<p className="text-ink-faint font-mono text-sm px-6 text-center">{statusLabel}</p>
</div>
) : null}
</div>
);
}
Expand All @@ -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 (
<div className="relative w-full h-full bg-code-bg">
<WorkbenchProvider initialCode={code}>
<FunnelViewerInner />
<div className="relative w-full h-full bg-code-bg" data-mesh-url={meshUrl ?? undefined}>
<WorkbenchProvider key={`${resetKey}:${code.length}`} initialCode={code}>
<FunnelViewerInner onPhaseChange={onPhaseChange} />
</WorkbenchProvider>
</div>
);
Expand Down
8 changes: 7 additions & 1 deletion src/studio/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -153,6 +156,9 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri
viewportBackground={viewportBackground}
planes={planes}
/>
{onDisplayReady ? (
<DisplayReadySensor geometries={geometries} onDisplayReady={onDisplayReady} />
) : null}
</Canvas>
<ViewGizmo
onNavigate={(target) => setNavigationRequest((prev) => ({
Expand Down
30 changes: 30 additions & 0 deletions src/studio/components/viewer/DisplayReadySensor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 47 additions & 0 deletions src/studio/components/viewer/DisplayReadySensor.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions src/studio/components/viewer/hasNonemptyGeometry.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
Loading
Loading