diff --git a/package.json b/package.json index da6236ad..d6117ff3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 84639239..1fdd98fd 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -8,6 +8,8 @@ import { ColorPicker } from '@shared/components/ui' import GenerationHUD from './components/GenerationHUD' import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' +import EditPanel from './components/EditPanel' +import { meshPathFromUrl } from './meshEditPanel' import { getDefaultAssetLibraryService } from './assetLibraryService' import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' @@ -677,6 +679,7 @@ export default function GeneratePage(): JSX.Element { const [librarySortMode, setLibrarySortMode] = useState('type') const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) + const [editPanelOpen, setEditPanelOpen] = useState(false) const dragging = useRef(false) const [selectedPointLightId, setSelectedPointLightId] = useState(null) // Populated by Viewer3D — undoes the latest live gizmo transform, if any. @@ -792,16 +795,6 @@ export default function GeneratePage(): JSX.Element { } } - function getOptimizePath(url: string): string { - if (url.startsWith('/workspace/')) { - return url.slice('/workspace/'.length) - } - if (url.startsWith('/optimize/serve-file?path=')) { - return decodeURIComponent(url.split('path=')[1] ?? '') - } - return url - } - async function handleImportMesh() { const filePath = await window.electron.fs.selectMeshFile() if (!filePath) return @@ -893,7 +886,7 @@ export default function GeneratePage(): JSX.Element { if (!currentJob?.outputUrl) return setSmoothing(true) try { - const path = getOptimizePath(currentJob.outputUrl) + const path = meshPathFromUrl(currentJob.outputUrl) const { url } = await smoothMesh(path, iterations) applyMeshEdit(url) setOpenPanel(null) @@ -908,7 +901,7 @@ export default function GeneratePage(): JSX.Element { if (!currentJob?.outputUrl) return setDecimating(true) try { - const path = getOptimizePath(currentJob.outputUrl) + const path = meshPathFromUrl(currentJob.outputUrl) const { url } = await optimizeMesh(path, targetFaces) applyMeshEdit(url) setOpenPanel(null) @@ -1261,20 +1254,41 @@ export default function GeneratePage(): JSX.Element { )} + + {/* Mesh edit panel: every registry op, runnable without the agent */} + - {/* Viewer area */} -
- - +
+ {/* Viewer area */} +
+ + +
+ {editPanelOpen && setEditPanelOpen(false)} />}
diff --git a/src/areas/generate/components/EditPanel.tsx b/src/areas/generate/components/EditPanel.tsx new file mode 100644 index 00000000..528e6cc5 --- /dev/null +++ b/src/areas/generate/components/EditPanel.tsx @@ -0,0 +1,647 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppStore } from '@shared/stores/appStore' +import { useApi } from '@shared/hooks/useApi' +import { Tooltip } from '@shared/components/ui' +import { + INSPECT_OP_ID, + LOG_SLIDER_STEPS, + apiErrorMessage, + coerceNumber, + defaultParamValues, + fromSliderPosition, + groupMeshOps, + inspectionBadges, + isMeshUpdate, + meshPathFromUrl, + paramControl, + requiresConfirmation, + stepSummaries, + summarizeDetails, + toSliderPosition, + type BadgeState, + type MeshOpDescriptor, + type MeshOpResponse, + type ParamControl, +} from '../meshEditPanel' + +// Side panel listing every mesh-ops registry op (the same ones the chat agent +// runs), with controls generated from each op's params_schema. Ops that write +// a mesh go through applyMeshEdit, so they land on the undo stack exactly like +// an agent edit; analysis ops show their report inline. + +type Params = Record + +interface OpResult { + /** Mesh URL the op ran on. */ + sourceUrl: string + response?: MeshOpResponse + error?: string +} + +interface Fix { + op: string + params?: Params + reason?: string +} + +function Spinner({ size = 11 }: { size?: number }): JSX.Element { + return ( + + + + ) +} + +function Chevron({ open }: { open: boolean }): JSX.Element { + return ( + + + + ) +} + +// ─── Param controls ─────────────────────────────────────────────────────────── + +const INPUT_CLASS = + 'bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-violet-500 transition-colors disabled:opacity-50' + +/** Number text field that commits (clamped) on blur or Enter. */ +function NumberField({ control, value, onChange, disabled }: { + control: Extract + value: number + onChange: (v: number) => void + disabled: boolean +}): JSX.Element { + const [text, setText] = useState(String(value)) + useEffect(() => { setText(String(value)) }, [value]) + + function commit() { + const parsed = Number(text.replace(',', '.')) + if (text.trim() === '' || !Number.isFinite(parsed)) { setText(String(value)); return } + const next = coerceNumber(control, parsed) + setText(String(next)) + if (next !== value) onChange(next) + } + + return ( + setText(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') commit() }} + className={`${INPUT_CLASS} w-20 shrink-0 text-right tabular-nums`} + /> + ) +} + +function ParamField({ control, value, onChange, disabled }: { + control: ParamControl + value: unknown + onChange: (v: unknown) => void + disabled: boolean +}): JSX.Element { + const label = ( +
+ {control.label} + {control.tooltip && ( + + ⓘ + + )} +
+ ) + + switch (control.kind) { + case 'toggle': { + const on = value === true + return ( +
+ {label} + +
+ ) + } + case 'select': + return ( +
+ {label} + +
+ ) + case 'slider': { + const numeric = typeof value === 'number' ? value : control.defaultValue + const log = control.scale === 'log' + return ( +
+ {label} +
+ onChange(fromSliderPosition(control, e.currentTarget.valueAsNumber))} + className="min-w-0 flex-1 accent-violet-500 cursor-pointer disabled:cursor-default" + /> + +
+
+ ) + } + case 'number': + return ( +
+ {label} + +
+ ) + case 'text': + return ( +
+ {label} + onChange(e.target.value)} + className={`${INPUT_CLASS} w-full`} + /> +
+ ) + } +} + +function ParamFields({ op, values, onChange, disabled }: { + op: MeshOpDescriptor + values: Params + onChange: (id: string, value: unknown) => void + disabled: boolean +}): JSX.Element | null { + if (op.params_schema.length === 0) return null + return ( +
+ {op.params_schema.map((schema) => { + const control = paramControl(schema) + return ( + onChange(schema.id, v)} + disabled={disabled} + /> + ) + })} +
+ ) +} + +// ─── Reports ────────────────────────────────────────────────────────────────── + +const BADGE_CLASS: Record = { + ok: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300', + bad: 'bg-red-500/10 border-red-500/30 text-red-300', + unknown: 'bg-zinc-800 border-zinc-700 text-zinc-400', +} + +const BADGE_MARK: Record = { ok: '✓', bad: '✗', unknown: '?' } + +function fmt(value: unknown, digits = 4): string { + if (typeof value !== 'number') return '—' + return Number.isInteger(value) ? value.toLocaleString('en-US') : Number(value.toPrecision(digits)).toString() +} + +function InspectionReport({ report }: { report: Record }): JSX.Element { + const dims = Array.isArray(report.dimensions) ? report.dimensions.map((d) => fmt(d)).join(' × ') : '—' + const selfHits = report.self_intersecting_face_count + const stats: [string, string][] = [ + ['Faces', fmt(report.face_count)], + ['Vertices', fmt(report.vertex_count)], + ['Parts', fmt(report.component_count)], + ['Holes', `${fmt(report.hole_count)}${report.boundary_edge_count ? ` (${fmt(report.boundary_edge_count)} open edges)` : ''}`], + ['Non-manifold edges', fmt(report.non_manifold_edge_count)], + ['Degenerate faces', fmt(report.degenerate_face_count)], + ['Self-intersecting faces', selfHits === null ? 'not checked' : fmt(selfHits)], + ['Dimensions', dims], + ['Volume', report.volume == null ? 'n/a (not closed)' : fmt(report.volume)], + ['UVs', report.has_uvs === true ? 'yes' : report.has_uvs === false ? 'no' : 'unknown'], + ['Normals', report.has_normals === true ? 'yes' : report.has_normals === false ? 'no' : 'unknown'], + ] + const issues = Array.isArray(report.issues) ? (report.issues as unknown[]).map(String) : [] + + return ( +
+
+ {inspectionBadges(report).map((badge) => ( + + {BADGE_MARK[badge.state]} {badge.label} + + ))} +
+
+ {stats.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ {issues.length > 0 && ( +
    + {issues.map((issue) =>
  • • {issue}
  • )} +
+ )} +
+ ) +} + +function DetailRows({ details }: { details: Record }): JSX.Element | null { + const steps = stepSummaries(details) + const rows = summarizeDetails(details) + if (steps.length === 0 && rows.length === 0) return null + return ( +
+ {steps.length > 0 && ( +
    + {steps.map((step, i) =>
  1. {i + 1}. {step}
  2. )} +
+ )} + {rows.length > 0 && ( +
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ )} +
+ ) +} + +// ─── Panel ──────────────────────────────────────────────────────────────────── + +export default function EditPanel({ onClose }: { onClose: () => void }): JSX.Element { + const apiUrl = useAppStore((s) => s.apiUrl) + const currentJob = useAppStore((s) => s.currentJob) + const applyMeshEdit = useAppStore((s) => s.applyMeshEdit) + const { listMeshOps, runMeshOp } = useApi() + + const [ops, setOps] = useState(null) + const [loadError, setLoadError] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + const [params, setParams] = useState>({}) + const [expanded, setExpanded] = useState(null) + // Op awaiting in-panel confirmation, with the params it will run with. + const [confirming, setConfirming] = useState<{ opId: string; values: Params } | null>(null) + const [running, setRunning] = useState(null) + const [results, setResults] = useState>({}) + const [inspection, setInspection] = useState<{ sourceUrl: string; report: Record } | null>(null) + + const isGenerating = currentJob?.status === 'uploading' || currentJob?.status === 'generating' + const meshUrl = currentJob?.status === 'done' ? currentJob.outputUrl : undefined + const canRun = !!meshUrl && !isGenerating && running === null + + useEffect(() => { + let cancelled = false + setLoadError(null) + listMeshOps() + .then((list) => { if (!cancelled) setOps(list) }) + .catch((err) => { if (!cancelled) setLoadError(apiErrorMessage(err)) }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps -- listMeshOps is rebuilt every render; refetch on API change or retry only + }, [apiUrl, reloadKey]) + + // Results and reports belong to one model: drop them when another is loaded. + const jobId = currentJob?.id + useEffect(() => { + setResults({}) + setInspection(null) + setConfirming(null) + }, [jobId]) + + const opById = useCallback((id: string) => ops?.find((o) => o.id === id), [ops]) + + function valuesFor(op: MeshOpDescriptor): Params { + return params[op.id] ?? defaultParamValues(op.params_schema) + } + + function setParam(op: MeshOpDescriptor, id: string, value: unknown) { + setParams((current) => ({ ...current, [op.id]: { ...(current[op.id] ?? defaultParamValues(op.params_schema)), [id]: value } })) + } + + async function run(op: MeshOpDescriptor, values: Params) { + if (!meshUrl || running !== null) return + const sourceUrl = meshUrl + setConfirming(null) + setRunning(op.id) + setResults((current) => { const next = { ...current }; delete next[op.id]; return next }) + try { + const response = await runMeshOp(op.id, meshPathFromUrl(sourceUrl), values) + // The viewer may have moved on (undo, another model) while the op ran: + // applying would silently drop that change, so discard the result. + if (useAppStore.getState().currentJob?.outputUrl !== sourceUrl) { + setResults((current) => ({ ...current, [op.id]: { sourceUrl, error: 'The mesh changed while this ran; the result was discarded.' } })) + return + } + if (isMeshUpdate(response)) applyMeshEdit(response.url) + if (op.id === INSPECT_OP_ID) setInspection({ sourceUrl, report: response }) + else if (response.report && typeof response.report === 'object') { + // suggest_fixes carries a full inspection report too. + setInspection({ sourceUrl, report: response.report as Record }) + } + setResults((current) => ({ ...current, [op.id]: { sourceUrl, response } })) + } catch (err) { + setResults((current) => ({ ...current, [op.id]: { sourceUrl, error: apiErrorMessage(err) } })) + } finally { + setRunning(null) + } + } + + function requestRun(op: MeshOpDescriptor, values: Params = valuesFor(op)) { + if (requiresConfirmation(op)) { setConfirming({ opId: op.id, values }); return } + void run(op, values) + } + + const inspectOp = ops?.find((o) => o.id === INSPECT_OP_ID) + const groups = ops ? groupMeshOps(ops) : [] + + return ( + + ) +} + +function OpResultView({ response, stale, canRun, opById, onRunFix }: { + response: MeshOpResponse + stale: boolean + canRun: boolean + opById: (id: string) => MeshOpDescriptor | undefined + onRunFix: (op: MeshOpDescriptor, params: Params) => void +}): JSX.Element { + if (isMeshUpdate(response)) { + return ( +
+

Applied — undo with Ctrl+Z.

+ +
+ ) + } + + // suggest_fixes: { report, fixes, notes } — the report goes to Mesh health. + if (Array.isArray(response.fixes)) { + const fixes = response.fixes as Fix[] + const notes = Array.isArray(response.notes) ? (response.notes as unknown[]).map(String) : [] + return ( +
+ {stale &&

The mesh changed since these suggestions.

} + {fixes.length === 0 && notes.length === 0 && ( +

No fixes needed — no problems detected.

+ )} + {fixes.map((fix, i) => { + const fixOp = opById(fix.op) + return ( +
+
+

{fixOp?.label ?? fix.op}

+ {fix.reason &&

{fix.reason}

} +
+ {fixOp && ( + + )} +
+ ) + })} + {notes.map((note) =>

{note}

)} +
+ ) + } + + return ( +
+ {stale &&

The mesh changed since this ran.

} + +
+ ) +} diff --git a/src/areas/generate/meshEditPanel.test.ts b/src/areas/generate/meshEditPanel.test.ts new file mode 100644 index 00000000..a78b5655 --- /dev/null +++ b/src/areas/generate/meshEditPanel.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + apiErrorMessage, + defaultParamValues, + fromSliderPosition, + groupMeshOps, + inspectionBadges, + isMeshUpdate, + meshPathFromUrl, + paramControl, + requiresConfirmation, + stepSummaries, + summarizeDetails, + toSliderPosition, + type MeshOpDescriptor, +} from './meshEditPanel.ts' + +function op(id: string, category: string, extra: Partial = {}): MeshOpDescriptor { + return { id, label: id, params_schema: [], destructive: false, undoable: true, category, ...extra } +} + +// ─── Schema → control ──────────────────────────────────────────────────────── + +test('a bounded int becomes an integer slider with its default', () => { + const control = paramControl({ id: 'iterations', label: 'Iterations', type: 'int', default: 5, min: 1, max: 50 }) + assert.equal(control.kind, 'slider') + assert.ok(control.kind === 'slider') + assert.equal(control.integer, true) + assert.equal(control.step, 1) + assert.equal(control.min, 1) + assert.equal(control.max, 50) + assert.equal(control.scale, 'linear') + assert.equal(control.defaultValue, 5) +}) + +test('a bounded float keeps its declared step', () => { + const control = paramControl({ id: 'lambda_', label: 'Strength', type: 'float', default: 0.5, min: 0.1, max: 1, step: 0.05 }) + assert.ok(control.kind === 'slider') + assert.equal(control.integer, false) + assert.equal(control.step, 0.05) +}) + +test('a range spanning three orders of magnitude uses a log slider', () => { + const faces = paramControl({ id: 'target_faces', type: 'int', default: 10000, min: 100, max: 1000000 }) + assert.ok(faces.kind === 'slider') + assert.equal(faces.scale, 'log') + const size = paramControl({ id: 'size', type: 'float', default: 1, min: 0.001, max: 100000, step: 0.1 }) + assert.ok(size.kind === 'slider') + assert.equal(size.scale, 'log') +}) + +test('a numeric param missing a bound is a plain number input', () => { + const control = paramControl({ id: 'n', type: 'int', default: 3, min: 0 }) + assert.equal(control.kind, 'number') + assert.ok(control.kind === 'number') + assert.equal(control.min, 0) + assert.equal(control.max, undefined) +}) + +test('boolean, select and string params map to toggle, dropdown and text', () => { + assert.equal(paramControl({ id: 'b', type: 'boolean', default: true }).kind, 'toggle') + const select = paramControl({ + id: 'mode', type: 'select', default: 'taubin', + options: [{ value: 'taubin', label: 'Taubin' }, { value: 'laplacian', label: 'Laplacian' }], + }) + assert.ok(select.kind === 'select') + assert.deepEqual(select.options.map((o) => o.value), ['taubin', 'laplacian']) + assert.equal(paramControl({ id: 's', type: 'string', default: 'x' }).kind, 'text') +}) + +test('the label falls back to a humanized id', () => { + assert.equal(paramControl({ id: 'max_hole_size', type: 'int', default: 1 }).label, 'Max hole size') +}) + +test('defaults fall back per type when the schema omits them', () => { + assert.equal(paramControl({ id: 'b', type: 'boolean' }).defaultValue, false) + assert.equal(paramControl({ id: 'n', type: 'int', min: 4, max: 9 }).defaultValue, 4) + assert.equal( + paramControl({ id: 'm', type: 'select', options: [{ value: 'a', label: 'A' }] }).defaultValue, + 'a', + ) +}) + +test('defaultParamValues collects every default by id', () => { + assert.deepEqual( + defaultParamValues([ + { id: 'iterations', type: 'int', default: 5, min: 1, max: 50 }, + { id: 'mode', type: 'select', default: 'taubin', options: [{ value: 'taubin', label: 'T' }] }, + { id: 'fill', type: 'boolean', default: true }, + ]), + { iterations: 5, mode: 'taubin', fill: true }, + ) +}) + +// ─── Slider scale ──────────────────────────────────────────────────────────── + +test('linear slider positions map straight through', () => { + const control = paramControl({ id: 'i', type: 'int', default: 5, min: 1, max: 50 }) + assert.ok(control.kind === 'slider') + assert.equal(toSliderPosition(control, 20), 20) + assert.equal(fromSliderPosition(control, 20), 20) +}) + +test('log slider positions round-trip and stay within bounds', () => { + const control = paramControl({ id: 'target_faces', type: 'int', default: 10000, min: 100, max: 1000000 }) + assert.ok(control.kind === 'slider') + assert.equal(fromSliderPosition(control, toSliderPosition(control, 100)), 100) + assert.equal(fromSliderPosition(control, toSliderPosition(control, 1000000)), 1000000) + assert.equal(fromSliderPosition(control, toSliderPosition(control, 10000)), 10000) + // Integers stay integers. + assert.ok(Number.isInteger(fromSliderPosition(control, 0.37))) +}) + +// ─── Grouping ──────────────────────────────────────────────────────────────── + +test('ops are grouped in the canonical category order with labels', () => { + const groups = groupMeshOps([ + op('decimate', 'topology'), + op('prepare_for_printing', 'presets'), + op('recenter', 'transform'), + op('repair', 'repair'), + ]) + assert.deepEqual(groups.map((g) => g.category), ['repair', 'transform', 'topology', 'presets']) + assert.deepEqual(groups.map((g) => g.label), ['Repair', 'Transform', 'Topology', 'Presets']) +}) + +test('ops keep registry order within a group', () => { + const groups = groupMeshOps([op('repair', 'repair'), op('recenter', 'transform'), op('repair_mesh', 'repair')]) + assert.deepEqual(groups[0].ops.map((o) => o.id), ['repair', 'repair_mesh']) +}) + +test('unknown categories still appear, after the known ones, alphabetically', () => { + const groups = groupMeshOps([ + op('z', 'zeta_tools'), + op('a', 'alpha_tools'), + op('repair', 'repair'), + ]) + assert.deepEqual(groups.map((g) => g.category), ['repair', 'alpha_tools', 'zeta_tools']) + assert.equal(groups[1].label, 'Alpha tools') +}) + +test('inspect_mesh is left out of the groups: it has its own report panel', () => { + const groups = groupMeshOps([op('inspect_mesh', 'analysis'), op('suggest_fixes', 'analysis')]) + assert.deepEqual(groups.map((g) => g.ops.map((o) => o.id)), [['suggest_fixes']]) +}) + +test('the registry optimization category sits next to topology as Optimize', () => { + const groups = groupMeshOps([op('smooth', 'optimization'), op('remesh', 'topology'), op('repair', 'repair')]) + assert.deepEqual(groups.map((g) => g.label), ['Repair', 'Topology', 'Optimize']) +}) + +test('an op without a category falls into Other', () => { + const groups = groupMeshOps([{ ...op('x', ''), category: undefined as unknown as string }]) + assert.equal(groups[0].label, 'Other') +}) + +// ─── Confirmation / results ────────────────────────────────────────────────── + +test('destructive or non-undoable ops need an in-panel confirmation', () => { + assert.equal(requiresConfirmation(op('a', 'repair')), false) + assert.equal(requiresConfirmation(op('a', 'repair', { destructive: true })), true) + assert.equal(requiresConfirmation(op('a', 'repair', { undoable: false })), true) +}) + +test('a response with a url is a mesh update; a report is not', () => { + assert.equal(isMeshUpdate({ url: '/workspace/a.glb', face_count: 3 }), true) + assert.equal(isMeshUpdate({ is_watertight: true }), false) + assert.equal(isMeshUpdate({ path: 'C:/outside.glb' }), false) +}) + +test('inspection badges read the report flags, null meaning unknown', () => { + const badges = inspectionBadges({ + is_watertight: true, + is_manifold: false, + is_winding_consistent: true, + is_inside_out: false, + printable: null, + }) + const byLabel = Object.fromEntries(badges.map((b) => [b.label, b.state])) + assert.equal(byLabel.Watertight, 'ok') + assert.equal(byLabel.Manifold, 'bad') + assert.equal(byLabel['Consistent winding'], 'ok') + // Inside-out is a problem flag: false is good. + assert.equal(byLabel['Normals outward'], 'ok') + assert.equal(byLabel.Printable, 'unknown') +}) + +test('summarizeDetails lists scalar fields and hides paths', () => { + const rows = summarizeDetails({ + url: '/workspace/a.glb', + path: 'a.glb', + face_count: 12000, + repaired: true, + steps: ['fill holes', 'fix normals'], + nested: { a: 1 }, + }) + assert.deepEqual(rows, [ + ['Face count', '12,000'], + ['Repaired', 'yes'], + ['Steps', 'fill holes, fix normals'], + ]) +}) + +// ─── Misc ──────────────────────────────────────────────────────────────────── + +test('meshPathFromUrl strips the workspace prefix and decodes serve-file urls', () => { + assert.equal(meshPathFromUrl('/workspace/col/a.glb'), 'col/a.glb') + assert.equal(meshPathFromUrl('/optimize/serve-file?path=C%3A%2Fm%2Fa.glb'), 'C:/m/a.glb') + assert.equal(meshPathFromUrl('col/a.glb'), 'col/a.glb') +}) + +test('apiErrorMessage prefers the FastAPI detail', () => { + assert.equal(apiErrorMessage({ response: { data: { detail: 'Unknown mesh operation: x' } }, message: 'Request failed' }), 'Unknown mesh operation: x') + assert.equal(apiErrorMessage(new Error('Network Error')), 'Network Error') + assert.equal(apiErrorMessage('boom'), 'boom') +}) + +test('stepSummaries reads the per-step summaries of a preset result', () => { + assert.deepEqual( + stepSummaries({ + steps: [ + { op: 'decimate', summary: 'Decimated to 5,000 triangles.' }, + { op: 'recompute_normals' }, + 'not a step', + { op: 'normalize_scale', summary: 'Scaled the longest side to 1 m.' }, + ], + }), + ['Decimated to 5,000 triangles.', 'Scaled the longest side to 1 m.'], + ) + assert.deepEqual(stepSummaries({ steps: 'nope' }), []) + assert.deepEqual(stepSummaries({}), []) +}) diff --git a/src/areas/generate/meshEditPanel.ts b/src/areas/generate/meshEditPanel.ts new file mode 100644 index 00000000..a987274b --- /dev/null +++ b/src/areas/generate/meshEditPanel.ts @@ -0,0 +1,288 @@ +// Pure logic behind the mesh edit panel: maps the mesh-ops registry +// (GET /optimize/ops) to UI controls and groups, and reads op results. +// Kept free of React so it can be unit-tested with node --test. + +/** One entry of an op's params_schema, as served by the backend registry. */ +export interface MeshOpParamSchema { + id: string + label?: string + type?: string + default?: unknown + min?: number + max?: number + step?: number + options?: { value: string | number; label: string }[] + tooltip?: string +} + +/** MeshOp.describe() on the backend. */ +export interface MeshOpDescriptor { + id: string + label: string + params_schema: MeshOpParamSchema[] + destructive: boolean + undoable: boolean + category: string +} + +/** POST /optimize/op/{id}: `url` when a mesh was written, otherwise the op's report. */ +export type MeshOpResponse = Record + +interface ControlBase { + id: string + label: string + tooltip?: string +} + +export type ParamControl = + | (ControlBase & { + kind: 'slider' + min: number + max: number + step: number + integer: boolean + /** 'log' for ranges spanning several orders of magnitude (face counts, sizes). */ + scale: 'linear' | 'log' + defaultValue: number + }) + | (ControlBase & { + kind: 'number' + min?: number + max?: number + step: number + integer: boolean + defaultValue: number + }) + | (ControlBase & { kind: 'toggle'; defaultValue: boolean }) + | (ControlBase & { + kind: 'select' + options: { value: string | number; label: string }[] + defaultValue: string | number + }) + | (ControlBase & { kind: 'text'; defaultValue: string }) + +/** A slider is switched to a log scale above this max/min ratio. */ +const LOG_SCALE_RATIO = 1000 +/** Resolution of a log slider's underlying . */ +export const LOG_SLIDER_STEPS = 1000 + +export function humanize(id: string): string { + const words = id.replace(/[_-]+/g, ' ').trim() + return words ? words.charAt(0).toUpperCase() + words.slice(1) : id +} + +export function paramControl(schema: MeshOpParamSchema): ParamControl { + const base: ControlBase = { + id: schema.id, + label: schema.label || humanize(schema.id), + tooltip: schema.tooltip, + } + const type = schema.type ?? (typeof schema.default === 'boolean' ? 'boolean' : 'float') + + if (type === 'boolean' || type === 'bool') { + return { ...base, kind: 'toggle', defaultValue: schema.default === true } + } + + if (type === 'select') { + const options = schema.options ?? [] + const fallback = options[0]?.value ?? '' + const defaultValue = (schema.default as string | number | undefined) ?? fallback + return { ...base, kind: 'select', options, defaultValue } + } + + if (type === 'int' || type === 'float' || type === 'number') { + const integer = type === 'int' + const step = schema.step ?? (integer ? 1 : 0.01) + const { min, max } = schema + const numericDefault = + typeof schema.default === 'number' ? schema.default : (min ?? 0) + if (min !== undefined && max !== undefined && max > min) { + const scale = min > 0 && max / min >= LOG_SCALE_RATIO ? 'log' : 'linear' + return { ...base, kind: 'slider', min, max, step, integer, scale, defaultValue: numericDefault } + } + return { ...base, kind: 'number', min, max, step, integer, defaultValue: numericDefault } + } + + return { ...base, kind: 'text', defaultValue: schema.default == null ? '' : String(schema.default) } +} + +export function defaultParamValues(schemas: MeshOpParamSchema[]): Record { + return Object.fromEntries(schemas.map((schema) => [schema.id, paramControl(schema).defaultValue])) +} + +// ─── Slider scale ──────────────────────────────────────────────────────────── + +type SliderControl = Extract + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +/** Value → position of the . */ +export function toSliderPosition(control: SliderControl, value: number): number { + if (control.scale === 'linear') return clamp(value, control.min, control.max) + const v = clamp(value, control.min, control.max) + const ratio = Math.log(v / control.min) / Math.log(control.max / control.min) + return Math.round(ratio * LOG_SLIDER_STEPS) +} + +/** Position of the → value, rounded to something typeable. */ +export function fromSliderPosition(control: SliderControl, position: number): number { + if (control.scale === 'linear') return clamp(position, control.min, control.max) + if (position <= 0) return control.min + if (position >= LOG_SLIDER_STEPS) return control.max + const raw = control.min * Math.pow(control.max / control.min, position / LOG_SLIDER_STEPS) + // Three significant figures: 12,345 → 12,300, 0.004567 → 0.00457. + const magnitude = Math.pow(10, Math.floor(Math.log10(raw)) - 2) + const rounded = Math.round(raw / magnitude) * magnitude + const value = control.integer ? Math.round(rounded) : Number(rounded.toPrecision(3)) + return clamp(value, control.min, control.max) +} + +/** Clamp a typed number to the control's bounds (integers rounded). */ +export function coerceNumber(control: Extract, value: number): number { + let v = control.integer ? Math.round(value) : value + if (control.min !== undefined) v = Math.max(control.min, v) + if (control.max !== undefined) v = Math.min(control.max, v) + return v +} + +// ─── Grouping ──────────────────────────────────────────────────────────────── + +/** Known categories, in display order. Anything else follows, alphabetically. */ +const CATEGORY_ORDER = ['repair', 'transform', 'topology', 'optimization', 'presets', 'analysis'] + +const CATEGORY_LABELS: Record = { + repair: 'Repair', + transform: 'Transform', + topology: 'Topology', + optimization: 'Optimize', + presets: 'Presets', + analysis: 'Analysis', +} + +/** Op shown in the dedicated report panel rather than in a group. */ +export const INSPECT_OP_ID = 'inspect_mesh' + +export interface MeshOpGroup { + category: string + label: string + ops: MeshOpDescriptor[] +} + +export function categoryLabel(category: string): string { + return CATEGORY_LABELS[category] ?? (category ? humanize(category) : 'Other') +} + +export function groupMeshOps(ops: MeshOpDescriptor[]): MeshOpGroup[] { + const byCategory = new Map() + for (const op of ops) { + if (op.id === INSPECT_OP_ID) continue + const category = op.category || '' + const list = byCategory.get(category) ?? [] + list.push(op) + byCategory.set(category, list) + } + + const rank = (category: string): number => { + const index = CATEGORY_ORDER.indexOf(category) + return index === -1 ? CATEGORY_ORDER.length : index + } + + return [...byCategory.entries()] + .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) + .map(([category, list]) => ({ category, label: categoryLabel(category), ops: list })) +} + +// ─── Results ───────────────────────────────────────────────────────────────── + +/** Destructive or non-undoable ops are confirmed in the panel before running. */ +export function requiresConfirmation(op: MeshOpDescriptor): boolean { + return op.destructive || !op.undoable +} + +/** True when the op wrote a mesh that should replace the one in the viewer. */ +export function isMeshUpdate(response: MeshOpResponse): response is MeshOpResponse & { url: string } { + return typeof response.url === 'string' && response.url.length > 0 +} + +export type BadgeState = 'ok' | 'bad' | 'unknown' + +export interface InspectionBadge { + label: string + state: BadgeState +} + +function flag(value: unknown, invert = false): BadgeState { + if (typeof value !== 'boolean') return 'unknown' + return value !== invert ? 'ok' : 'bad' +} + +/** ✓/✗ badges of an inspect_mesh report. */ +export function inspectionBadges(report: Record): InspectionBadge[] { + return [ + { label: 'Printable', state: flag(report.printable) }, + { label: 'Watertight', state: flag(report.is_watertight) }, + { label: 'Manifold', state: flag(report.is_manifold) }, + { label: 'Consistent winding', state: flag(report.is_winding_consistent) }, + { label: 'Normals outward', state: flag(report.is_inside_out, true) }, + ] +} + +const HIDDEN_DETAIL_KEYS = new Set(['url', 'path']) + +function formatScalar(value: unknown): string | null { + if (typeof value === 'boolean') return value ? 'yes' : 'no' + if (typeof value === 'number') { + return Number.isInteger(value) ? value.toLocaleString('en-US') : value.toPrecision(4).replace(/\.?0+$/, '') + } + if (typeof value === 'string') return value + return null +} + +/** Label/value rows for the scalar (and scalar-list) fields of an op result. */ +export function summarizeDetails(details: Record): [string, string][] { + const rows: [string, string][] = [] + for (const [key, value] of Object.entries(details)) { + if (HIDDEN_DETAIL_KEYS.has(key) || value == null) continue + if (Array.isArray(value)) { + const parts = value.map(formatScalar) + if (parts.length && parts.every((p) => p !== null)) rows.push([humanize(key), parts.join(', ')]) + continue + } + const text = formatScalar(value) + if (text !== null) rows.push([humanize(key), text]) + } + return rows +} + +/** One line per step of a composite op (the prepare_for_* presets report `steps`). */ +export function stepSummaries(details: Record): string[] { + if (!Array.isArray(details.steps)) return [] + return details.steps.flatMap((step) => { + const summary = step && typeof step === 'object' ? (step as { summary?: unknown }).summary : undefined + return typeof summary === 'string' && summary ? [summary] : [] + }) +} + +// ─── Misc ──────────────────────────────────────────────────────────────────── + +/** Viewer mesh URL → the `path` the optimize routes expect. */ +export function meshPathFromUrl(url: string): string { + if (url.startsWith('/workspace/')) return url.slice('/workspace/'.length) + if (url.startsWith('/optimize/serve-file?path=')) { + return decodeURIComponent(url.split('path=')[1] ?? '') + } + return url +} + +/** Readable message of a failed API call: FastAPI's `detail` when present. */ +export function apiErrorMessage(err: unknown): string { + if (err && typeof err === 'object') { + const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail + if (typeof detail === 'string' && detail) return detail + const message = (err as { message?: unknown }).message + if (typeof message === 'string' && message) return message + } + return String(err) +} diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index f013f1c1..d2c7104e 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { useAppStore, GenerationOptions } from '@shared/stores/appStore' +import type { MeshOpDescriptor, MeshOpResponse } from '@areas/generate/meshEditPanel' export function useApi() { const apiUrl = useAppStore((s) => s.apiUrl) @@ -129,5 +130,25 @@ export function useApi() { return { url: data.url } } - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, getAllModelsStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } + // Mesh-ops registry: every op the chat agent can run, with its params schema. + async function listMeshOps(): Promise { + const { data } = await client.get('/optimize/ops') + return data + } + + // Runs one registry op. The response carries `url` when a new mesh was + // written; analysis ops (inspect_mesh, suggest_fixes) return only a report. + async function runMeshOp( + opId: string, + path: string, + params: Record, + ): Promise { + const { data } = await client.post(`/optimize/op/${encodeURIComponent(opId)}`, { + path, + params, + }) + return data + } + + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, getAllModelsStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh, listMeshOps, runMeshOp } }