diff --git a/src/extensions/kinemage/behavior.ts b/src/extensions/kinemage/behavior.ts index 7ff55003a..d4662253e 100644 --- a/src/extensions/kinemage/behavior.ts +++ b/src/extensions/kinemage/behavior.ts @@ -22,33 +22,18 @@ import { Kinemage } from './reader/schema'; import { DataFormatProvider } from '../../mol-plugin-state/formats/provider'; import { Camera } from '../../mol-canvas3d/camera'; import { PluginCommands } from '../../mol-plugin/commands'; -import { StateObjectRef } from '../../mol-state'; import { getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object'; import { KinemageControls } from './ui'; +import { StateObjectSelector } from '../../mol-state'; const Tag = KinemageData.Tag; const Transform = StateTransformer.builderFactory('sb-kinemage'); -let g_kinemageData: KinemageData | undefined = undefined; - -/** Getter for external code / handlers to obtain the loaded kinemage runtime data. */ -export function getLoadedKinemageData() { - return g_kinemageData; -} - /** - * Map that keeps track of created shape/repr selectors for each created `Kinemage`. - * This lets callback handlers destroy / re-create shapes for a given `kinData`. - * Key: the `Kinemage` instance (object identity), Value: array of selectors produced - * by the state builder for the created shape/provider/representation transforms. + * State object to hold parsed Kinemage data */ -const g_kinemageShapeSelectors = new Map[]>(); - -/** Getter for external code / handlers to obtain the selectors for a specific kinemage. */ -export function getKinemageShapeSelectors(kin: Kinemage) { - return g_kinemageShapeSelectors.get(kin) || []; -} +export class KinemageObject extends PluginStateObject.Create({ name: 'Kinemage', typeClass: 'Object' }) { } /** * Apply a saved snapshot object (from a view state node) to the plugin camera. @@ -74,64 +59,188 @@ export async function applyViewSnapshot(plugin: PluginContext, snapshot: Partial await PluginCommands.Camera.SetSnapshot(plugin, { snapshot }); } +/** + * Transform to parse Kinemage data from string/data input + */ +export const ParseKinemage = Transform({ + name: 'sb-kinemage-parse', + display: { name: 'Parse Kinemage' }, + from: [PluginStateObject.Data.String], + to: KinemageObject, + params: { + label: PD.Optional(PD.Text('', { description: 'Label for the Kinemage data' })) + } +})({ + apply({ a, params }) { + return Task.create('Parse Kinemage', async ctx => { + const input = a.data; + let data: KinemageData; + + if (typeof input === 'string') { + // Parse from string content + const file = new File([input], 'input.kin', { type: 'text/plain' }); + data = await KinemageData.open(file); + } else { + throw new Error('Unsupported input type for ParseKinemage'); + } + + // Precompute camera snapshots for all views in all kinemages + for (const kinData of data.kinemages) { + (kinData as any).viewSnapshots = (kinData as any).viewSnapshots || Object.create(null); + for (const [viewKey, viewObj] of Object.entries(kinData.viewDict)) { + const center = Vec3.create(0, 0, 0); + if (viewObj.center) { + Vec3.set(center, viewObj.center[0], viewObj.center[1], viewObj.center[2]); + } + + const orientation: Mat3 = Mat3.identity(); + if (viewObj.matrix) { + Mat3.fromArray(orientation, viewObj.matrix, 0); + Mat3.transpose(orientation, orientation); + } + + const zAxis = Vec3.create(0, 0, 1); + Vec3.transformMat3(zAxis, zAxis, orientation); + + const yAxis = Vec3.create(0, 1, 0); + Vec3.transformMat3(yAxis, yAxis, orientation); + + let distance = 100; + if (viewObj.span) { + distance = viewObj.span; + } + Vec3.scale(zAxis, zAxis, distance); + const position = Vec3.create(0, 0, 100); + Vec3.add(position, center, zAxis); + + let radius = 100; + if (viewObj.zslab) { + const scale = viewObj.zslab / 200; + radius = 0.5 * distance * scale; + } + + const snap: Camera.Snapshot = { + mode: 'orthographic', + fov: Math.PI / 4, + position, + up: yAxis, + target: center, + radius, + radiusMax: 1e4, + fog: 0, + clipFar: true, + minNear: 1, + minFar: 1 + }; + + (kinData as any).viewSnapshots[viewKey] = snap; + } + } + + const label = params.label || data.kinemages[0]?.caption || 'Kinemage'; + return new KinemageObject(data, { label, description: `Kinemage with ${data.kinemages.length} view(s)` }); + }); + } +}); + +/** + * Transform to select a specific kinemage from parsed data + */ +export const SelectKinemage = Transform({ + name: 'sb-kinemage-select', + display: { name: 'Select Kinemage' }, + from: KinemageObject, + to: PluginStateObject.Format.Json, + params: (a) => { + const kinemages = a?.data?.kinemages || []; + const options = kinemages.map((k: Kinemage, i: number) => [i, k.pdbfile || k.caption || `Kinemage ${i}`] as const); + return { + index: PD.Select(0, options, { description: 'Which kinemage to use' }) + }; + } +})({ + apply({ a, params }) { + return Task.create('Select Kinemage', async ctx => { + const kinData = a.data.kinemages[params.index]; + if (!kinData) { + throw new Error(`No kinemage found at index ${params.index}`); + } + + const label = kinData.pdbfile || kinData.caption || `Kinemage ${params.index}`; + + // Store the kinemage data in a Format.Json node so downstream transforms can access it + return new PluginStateObject.Format.Json( + { kinData }, + { label, description: kinData.text || '' } + ); + }); + } +}); + export const KinemageShapePointsProvider = Transform({ name: 'sb-kinemage-shape-points-provider', display: { name: 'Kinemage Shape Points Provider' }, - from: PluginStateObject.Root, + from: PluginStateObject.Format.Json, to: PluginStateObject.Shape.Provider, - params: { - data: PD.Value(undefined as any, {}) - } + params: {} })({ - apply({ params }) { + apply({ a }) { return Task.create('Kinemage Points Shape Provider', async ctx => { - // shapeFromKin returns a Task that resolves to a ShapeProvider-like object - const provider = await shapePointsFromKin(params.data, { transforms: undefined }, 'Dots').runInContext(ctx); + const kinData = (a.data as any).kinData as Kinemage; + if (!kinData) { + throw new Error('No kinData found in parent Format.Json node'); + } + + const provider = await shapePointsFromKin(kinData, { transforms: undefined }, 'Dots').runInContext(ctx); return new PluginStateObject.Shape.Provider(provider as any, { - label: params.data.pdbfile || params.data.caption || 'Kinemage Points', - description: params.data.text || '' + label: kinData.pdbfile || kinData.caption || 'Kinemage Points', + description: kinData.text || '' }); }); } }); export const KinemageShapeLinesProvider = Transform({ - name: 'sb-kinemage-shape-lines-provider', - display: { name: 'Kinemage Shape Lines Provider' }, - from: PluginStateObject.Root, - to: PluginStateObject.Shape.Provider, - params: { - data: PD.Value(undefined as any, {}) - } - })({ - apply({ params }) { - return Task.create('Kinemage Lines Shape Provider', async ctx => { - // shapeFromKin returns a Task that resolves to a ShapeProvider-like object - const provider = await shapeLinesFromKin(params.data).runInContext(ctx); - return new PluginStateObject.Shape.Provider(provider as any, { - label: params.data.pdbfile || params.data.caption || 'Kinemage Lines', - description: params.data.text || '' - }); - }); - } + name: 'sb-kinemage-shape-lines-provider', + display: { name: 'Kinemage Shape Lines Provider' }, + from: PluginStateObject.Format.Json, + to: PluginStateObject.Shape.Provider, + params: {} +})({ + apply({ a }) { + return Task.create('Kinemage Lines Shape Provider', async ctx => { + const kinData = (a.data as any).kinData as Kinemage; + if (!kinData) { + throw new Error('No kinData found in parent Format.Json node'); + } + + const provider = await shapeLinesFromKin(kinData).runInContext(ctx); + return new PluginStateObject.Shape.Provider(provider as any, { + label: kinData.pdbfile || kinData.caption || 'Kinemage Lines', + description: kinData.text || '' + }); + }); + } }); export const KinemageShapeMeshProvider = Transform({ name: 'sb-kinemage-shape-mesh-provider', display: { name: 'Kinemage Shape Mesh Provider' }, - from: PluginStateObject.Root, + from: PluginStateObject.Format.Json, to: PluginStateObject.Shape.Provider, - params: { - data: PD.Value(undefined as any, {}) - } + params: {} })({ - apply({ params }) { + apply({ a }) { return Task.create('Kinemage Mesh Shape Provider', async ctx => { - // shapeFromKin returns a Task that resolves to a ShapeProvider-like object - const provider = await shapeMeshFromKin(params.data).runInContext(ctx); + const kinData = (a.data as any).kinData as Kinemage; + if (!kinData) { + throw new Error('No kinData found in parent Format.Json node'); + } + + const provider = await shapeMeshFromKin(kinData).runInContext(ctx); return new PluginStateObject.Shape.Provider(provider as any, { - label: params.data.pdbfile || params.data.caption || 'Kinemage Meshes', - description: params.data.text || '' + label: kinData.pdbfile || kinData.caption || 'Kinemage Meshes', + description: kinData.text || '' }); }); } @@ -140,19 +249,21 @@ export const KinemageShapeMeshProvider = Transform({ export const KinemageShapeSpheresProvider = Transform({ name: 'sb-kinemage-shape-spheres-provider', display: { name: 'Kinemage Shape Spheres Provider' }, - from: PluginStateObject.Root, + from: PluginStateObject.Format.Json, to: PluginStateObject.Shape.Provider, - params: { - data: PD.Value(undefined as any, {}) - } + params: {} })({ - apply({ params }) { + apply({ a }) { return Task.create('Kinemage Spheres Shape Provider', async ctx => { - // shapeFromKin returns a Task that resolves to a ShapeProvider-like object - const provider = await shapeSpheresFromKin(params.data).runInContext(ctx); + const kinData = (a.data as any).kinData as Kinemage; + if (!kinData) { + throw new Error('No kinData found in parent Format.Json node'); + } + + const provider = await shapeSpheresFromKin(kinData).runInContext(ctx); return new PluginStateObject.Shape.Provider(provider as any, { - label: params.data.pdbfile || params.data.caption || 'Kinemage Spheres', - description: params.data.text || '' + label: kinData.pdbfile || kinData.caption || 'Kinemage Spheres', + description: kinData.text || '' }); }); } @@ -174,7 +285,6 @@ export const KinemageExtension = PluginBehavior.create<{ autoAttach: boolean }>( this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach); // Register right-panel controls for Kinemage (show in the right-hand inspector) - // Register both as a structure-scoped control and (if available) as a global control this.ctx.customStructureControls.set(Tag.Representation, KinemageControls as any); // Some app hosts expose a global customControls registry; register there too so the card is visible // even when no structure is loaded. Use `any` guards to avoid type errors if customControls isn't present. @@ -225,164 +335,97 @@ interface DragAndDropHandler { handle: PluginDragAndDropHandler, } -/** Helper function to create the shapes for a kinemage */ -export async function createShapesForKinemage(plugin: PluginContext, update: StateBuilder.Root, kinData: Kinemage) { - // Keep list of created selectors for this kinemage (shapes / representations etc.) - const createdShapeSelectors: StateObjectRef[] = []; +/** Helper function to create all shapes for a kinemage via proper transform chain */ +async function createShapesForKinemage(plugin: PluginContext, update: StateBuilder.Root, kinDataSelector: StateObjectSelector) { + const kinDataCell = plugin.state.data.cells.get(kinDataSelector.ref); + if (!kinDataCell?.obj?.data) return; - // Generate all of the shapes for this kinemage, each shape type having its own provider and representation. - // Make all of their GUI buttons ghosted -- we'll control visibility using Kinemage master and group settings + const kinData = (kinDataCell.obj.data as any).kinData as Kinemage; + if (!kinData) return; + + // Generate all shape types that have data, each as child of the selected kinemage if (kinData.dotLists.length > 0) { - const node = await update - .toRoot() - .apply(KinemageShapePointsProvider, { data: kinData }, { state: { isGhost: true } }) + await update + .to(kinDataSelector) + .apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } }) .apply(StateTransforms.Representation.ShapeRepresentation3D); - createdShapeSelectors.push(node.selector as StateObjectRef); } if (kinData.vectorLists.length > 0) { - const node = await update - .toRoot() - .apply(KinemageShapeLinesProvider, { data: kinData }, { state: { isGhost: true } }) + await update + .to(kinDataSelector) + .apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } }) .apply(StateTransforms.Representation.ShapeRepresentation3D); - createdShapeSelectors.push(node.selector as StateObjectRef); } if (kinData.ribbonLists.length > 0) { - const node = await update - .toRoot() - .apply(KinemageShapeMeshProvider, { data: kinData }, { state: { isGhost: true } }) + await update + .to(kinDataSelector) + .apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } }) .apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true }); - createdShapeSelectors.push(node.selector as StateObjectRef); } if (kinData.ballLists.length > 0) { - const node = await update - .toRoot() - .apply(KinemageShapeSpheresProvider, { data: kinData }, { state: { isGhost: true } }) + await update + .to(kinDataSelector) + .apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } }) .apply(StateTransforms.Representation.ShapeRepresentation3D); - createdShapeSelectors.push(node.selector as StateObjectRef); - } - - // Store the created selector list for this kinemage so callback handlers can destroy / re-create. - if (createdShapeSelectors.length > 0) { - g_kinemageShapeSelectors.set(kinData as Kinemage, createdShapeSelectors); } } -/** Helper function to destroy all previously-made shapes for a kinemage - * (soft remove: hide the transforms so visuals are removed from scene) */ -export async function destroyShapesForKinemage(plugin: PluginContext, kinData: Kinemage) { - const createdShapeSelectors = g_kinemageShapeSelectors.get(kinData as Kinemage); - if (!createdShapeSelectors) return; +/** Helper function to rebuild shapes for a kinemage (remove and recreate) */ +export async function rebuildShapesForKinemage(plugin: PluginContext, kinDataSelector: StateObjectSelector) { + // Store current camera snapshot + const curSnap = (plugin.canvas3d && (plugin.canvas3d as any).camera && (plugin.canvas3d as any).camera.getSnapshot) + ? (plugin.canvas3d as any).camera.getSnapshot() + : undefined; - for (const selector of createdShapeSelectors) { - try { - const ref = resolveSelectorRef(selector); - if (ref) { - // Fully remove the transform from the state tree so the nodes are gone (not just hidden). - // Use the plugin command so the removal is handled in the same place as other UI removals. - try { - await PluginCommands.State.RemoveObject(plugin, { state: plugin.state.data, ref, removeParentGhosts: true }); - } catch (e) { - console.warn('Failed to remove state object via command, falling back to hiding', ref, e); - // fallback: mark transform as hidden so visuals are torn down - plugin.state.data.updateCellState(ref, (old: any) => { - const s = { ...(old || {}) }; - s.isHidden = true; - return s; - }); - } - } else if ((selector as any).destroy) { - // Fallback if selector object exposes destroy (unlikely for state refs) - (selector as any).destroy(); - } else { - console.warn('Could not resolve selector to a ref for destruction', selector); - } - } catch (e) { - console.warn('Failed to destroy selector', selector, e); - } - // Commit canvas / repaint if needed - plugin.canvas3d?.commit(); - } - - g_kinemageShapeSelectors.delete(kinData as Kinemage); -} - -/** Centralized helper to apply kinemage content into plugin state (re-used by drag handler and programmatic loader) - * It computes view snapshots and stores them on the kinemage objects (runtime-only) and then creates shapes. - */ -async function applyKinemageInfoToState(plugin: PluginContext, kinInfo: KinemageData) { const update = plugin.state.data.build(); - for (const kinData of kinInfo.kinemages) { - // Skip kinemages we've already created shapes for - if (g_kinemageShapeSelectors.has(kinData)) continue; - - // Precompute snapshots for views and attach them to kinData so the right-panel UI can apply them. - (kinData as any).viewSnapshots = (kinData as any).viewSnapshots || Object.create(null); - for (const [viewKey, viewObj] of Object.entries(kinData.viewDict)) { - //const viewName = viewObj.name || `View ${viewKey}`; - - const center = Vec3.create(0, 0, 0); - if (viewObj.center) { - Vec3.set(center, viewObj.center[0], viewObj.center[1], viewObj.center[2]); - } - - const orientation: Mat3 = Mat3.identity(); - if (viewObj.matrix) { - Mat3.fromArray(orientation, viewObj.matrix, 0); - Mat3.transpose(orientation, orientation); - } - - const zAxis = Vec3.create(0, 0, 1); - Vec3.transformMat3(zAxis, zAxis, orientation); - - const yAxis = Vec3.create(0, 1, 0); - Vec3.transformMat3(yAxis, yAxis, orientation); - - let distance = 100; - if (viewObj.span) { - distance = viewObj.span; - } - Vec3.scale(zAxis, zAxis, distance); - const position = Vec3.create(0, 0, 100); - Vec3.add(position, center, zAxis); - - let radius = 100; - if (viewObj.zslab) { - const scale = viewObj.zslab / 200; - radius = 0.5 * distance * scale; - } - - const snap: Camera.Snapshot = { - mode: 'orthographic', - fov: Math.PI / 4, - position, - up: yAxis, - target: center, - radius, - radiusMax: 1e4, - fog: 0, - clipFar: true, - minNear: 1, - minFar: 1 - }; - - (kinData as any).viewSnapshots[viewKey] = snap; + // Remove all children of this kinemage node (shapes/representations) + const children = plugin.state.data.tree.children.get(kinDataSelector.ref); + if (children) { + for (const childRef of children.values()) { + update.delete(childRef); } - - // Ensure runtime store contains this kinData (loadKinemageFile already appends, but be safe) - if (!g_kinemageData) g_kinemageData = { kinemages: [], activeKinemage: -1 }; - if (!g_kinemageData.kinemages.includes(kinData)) { - g_kinemageData.kinemages.push(kinData); - g_kinemageData.activeKinemage = g_kinemageData.kinemages.length - 1; - } - - // Create shapes only for this new kinemage - await createShapesForKinemage(plugin, update, kinData); } + // Recreate shapes + await createShapesForKinemage(plugin, update, kinDataSelector); + await update.commit(); + + // Restore camera + if (curSnap) { + try { + await applyViewSnapshot(plugin, curSnap); + } catch (e) { + console.warn('Failed to restore camera snapshot after recreating shapes', e); + } + } +} + +/** Centralized helper to apply kinemage content into plugin state */ +async function applyKinemageToState(plugin: PluginContext, data: string, label?: string) { + const update = plugin.state.data.build(); + + // Create String data node + const dataNode = update + .toRoot() + .apply(StateTransforms.Data.RawData, { data, label: label || 'Kinemage File' }); + + // Parse into KinemageObject + const parsedNode = dataNode + .apply(ParseKinemage, { label }); + + // Select first kinemage (default) + const selectedNode = parsedNode + .apply(SelectKinemage, { index: 0 }); + await update.commit(); - // helper: wait briefly until the plugin bounding sphere has non-zero radius (or timeout) + // Now create shapes from the selected kinemage + const shapeUpdate = plugin.state.data.build(); + await createShapesForKinemage(plugin, shapeUpdate, selectedNode.selector); + await shapeUpdate.commit(); + + // Wait for bounding sphere and focus camera async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -395,136 +438,101 @@ async function applyKinemageInfoToState(plugin: PluginContext, kinInfo: Kinemage return null; } - // After commit, focus camera as before... try { const bs = await waitForNonEmptyBoundingSphere(plugin); if (bs && bs.radius > 0 && plugin.canvas3d) { await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 }); plugin.canvas3d?.commit(); - } else { - console.log('Did not get a valid bounding sphere after waiting, applying initial view snapshot without adjustment'); } } catch (e) { console.warn('Failed to apply initial kinemage view snapshot', e); } -} -// Helper: robustly resolve a transform ref from different selector shapes without changing other modules. -function resolveSelectorRef(sel: any): string | undefined { - if (!sel) return undefined; - if (typeof sel === 'string') return sel; - if (sel.ref && typeof sel.ref === 'string') return sel.ref; - if (sel.transform && typeof sel.transform.ref === 'string') return sel.transform.ref; - if (sel.cell && sel.cell.transform && typeof sel.cell.transform.ref === 'string') return sel.cell.transform.ref; - try { - // In case the runtime provides a utility on the ref type - return (StateObjectRef as any).resolveRef ? (StateObjectRef as any).resolveRef(sel as any) : undefined; - } catch { - return undefined; - } + return selectedNode.selector; } /** Programmatic loader: load a single File (a .kin) into the plugin state. - * Runs the import inside a Task so it has a runtime and asset context similar to drag-and-drop. - * Returns true if at least one Kinemage was added. + * Returns the ref to the selected kinemage node. */ -export async function loadKinemageFile(plugin: PluginContext, file: File): Promise { - let applied = false; - const task = Task.create('Load KIN file', async ctx => { - const kinData = await KinemageData.open(file); - // Append to runtime store instead of replacing it - if (!g_kinemageData) { - g_kinemageData = { kinemages: [], activeKinemage: -1 }; - } - g_kinemageData.kinemages.push(...kinData.kinemages); - g_kinemageData.activeKinemage = g_kinemageData.kinemages.length - 1; - applied = g_kinemageData.kinemages.length > 0; - }); - await plugin.runTask(task); - return applied; +export async function loadKinemageFile(plugin: PluginContext, file: File): Promise | undefined> { + const content = await file.text(); + return await applyKinemageToState(plugin, content, file.name); } /** DragAndDropHandler handler for `.kin` files */ const KinemageDragAndDropHandler: DragAndDropHandler = { name: 'kin', - /** Load .kin files. Append to previous plugin state. - * If multiple files are provided, append them all. - * Select the last-loaded one from the list. - * Return `true` if at least one file has been loaded. */ async handle(files: File[], plugin: PluginContext): Promise { let applied = false; for (const file of files) { if (file.name.toLowerCase().endsWith('.kin')) { - // reuse programmatic loader so drag & drop and programmatic loading behave the same - const ok = await loadKinemageFile(plugin, file); - applied = applied || ok; - if (g_kinemageData) { - await applyKinemageInfoToState(plugin, g_kinemageData); - } + const ref = await loadKinemageFile(plugin, file); + applied = applied || !!ref; } } return applied; }, }; -/* Convert a string to a file if needed so that our file loader can handle it properly. */ -/// @todo Consider making the handler be able to deal with a string to avoid extra work here. -function fileFromPayload(data: any): File { - // If it's already a File or wrapped File, use name + size as signature (ignore lastModified to be more robust - // when different File instances are created from same content). - if (data instanceof File) { - return data; - } - if (data?.input instanceof File) { - const f: File = data.input.file; - return f; - } - if (data?.data && typeof data.data === 'string') { - const name = data.name || 'import.kin'; - const content = data.data as string; - const file = new File([content], name, { type: 'text/plain' }); - return file; - } - if (typeof data === 'string') { - const file = new File([data], 'import.kin', { type: 'text/plain' }); - return file; - } - - // Fallback: stringify & use length + prefix - try { - const s = String(data); - const file = new File([s], 'import.kin', { type: 'text/plain' }); - return file; - } catch { - // Last resort, use a unique key so we don't accidentally collide - const file = new File([''], 'import.kin', { type: 'text/plain' }); - return file; - } -} - const KINFormatProvider: DataFormatProvider<{}, any, any> = DataFormatProvider({ label: 'KIN', description: 'Kinemage', category: 'Miscellaneous', - // accept common casings stringExtensions: ['kin', 'KIN'], parse: async (plugin, data) => { try { - const file = fileFromPayload(data); - await loadKinemageFile(plugin, file); + // data is already a StateObjectRef to the raw data in the tree + // Build the transform chain from it + const builder = plugin.state.data.build() + .to(data) + .apply(ParseKinemage, {}); + + const selectedKin = builder + .apply(SelectKinemage, { index: 0 }); + + await builder.commit(); + + // Return the selector for the selected kinemage so visuals can use it + return { selectedKin: selectedKin.selector }; } catch (e) { console.error('Failed to parse KIN file', e); throw e; } - // no persistent state object produced here (data gets applied as representations), so return undefined - return undefined; }, visuals: async (plugin, data) => { - if (g_kinemageData) { - await applyKinemageInfoToState(plugin, g_kinemageData); - } else { - console.warn('[Kinemage] visuals: no loaded kinemage data present'); + if (!data?.selectedKin) { + console.warn('[Kinemage] visuals: no selectedKin ref provided'); + return; } + + // Create shapes from the selected kinemage + const shapeBuilder = plugin.state.data.build(); + await createShapesForKinemage(plugin, shapeBuilder, data.selectedKin); + await shapeBuilder.commit(); + + // Wait for bounding sphere and focus camera + async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const bs = getPluginBoundingSphere(plugin); + if (bs && bs.radius > 0) return bs; + } catch { /* ignore */ } + await new Promise(r => setTimeout(r, pollMs)); + } + return null; + } + + try { + const bs = await waitForNonEmptyBoundingSphere(plugin); + if (bs && bs.radius > 0 && plugin.canvas3d) { + await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 }); + plugin.canvas3d?.commit(); + } + } catch (e) { + console.warn('Failed to focus camera on kinemage', e); + } + return undefined; } }); diff --git a/src/extensions/kinemage/ui.tsx b/src/extensions/kinemage/ui.tsx index d431d3a4d..a0f9d59d2 100644 --- a/src/extensions/kinemage/ui.tsx +++ b/src/extensions/kinemage/ui.tsx @@ -15,10 +15,11 @@ import * as React from 'react'; import { CollapsableState, CollapsableControls } from '../../mol-plugin-ui/base'; import { Camera } from '../../mol-canvas3d/camera'; -import { applyViewSnapshot, createShapesForKinemage, destroyShapesForKinemage, getLoadedKinemageData } from './behavior'; +import { applyViewSnapshot, rebuildShapesForKinemage } from './behavior'; +import { Kinemage } from './reader/schema'; interface KinemageControlState extends CollapsableState { - isBusy: boolean + isBusy: boolean } function nameFromString(s: string | undefined) { @@ -29,246 +30,210 @@ function nameFromString(s: string | undefined) { } export class KinemageControls extends CollapsableControls<{}, KinemageControlState> { - protected defaultState(): KinemageControlState { - return { - header: 'Kinemage', - isCollapsed: false, - isBusy: false, - // default hidden until a kinemage is present - isHidden: true, - brand: { accent: 'cyan', svg: undefined as any } - }; - } + protected defaultState(): KinemageControlState { + return { + header: 'Kinemage', + isCollapsed: false, + isBusy: false, + // default hidden until a kinemage is present + isHidden: true, + brand: { accent: 'cyan', svg: undefined as any } + }; + } - componentDidMount() { - // Listen for shape/state changes: when state tree cells are created or removed the visuals changed. - // Use plugin.state.data.events.cell.created specifically to detect when kinemage-related transforms are added. - this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e)); - this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved()); - // also track cell state updates that may change labels / visibility - this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate()); + componentDidMount() { + // Listen for shape/state changes: when state tree cells are created or removed the visuals changed. + this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e)); + this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved()); + // also track cell state updates that may change labels / visibility + this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate()); - // ensure initial visibility reflects current runtime store / state - this.updateVisibility(); - } + // ensure initial visibility reflects current state + this.updateVisibility(); + } - private onCellCreated(e: any) { - try { - const cell = e?.cell; - const obj = cell?.obj; - // If the created cell carries kinemage runtime data, show the control - if (obj && obj.data && (obj.data as any).kinData) { - this.setState({ isHidden: false }); - return; - } - } catch { /* ignore */ } - // fallback: re-evaluate visibility from runtime store - this.updateVisibility(); - } + private onCellCreated(e: any) { + this.updateVisibility(); + } - private onCellRemoved() { - // Recompute whether any kinemage-related cells still exist in the state tree. - try { - const cells = (this.plugin.state.data as any).cells as Map; - for (const [, entry] of cells) { - const obj = (entry as any).obj; - if (obj && obj.data && (obj.data as any).kinData) { - // still have kinemage cell(s); keep visible - this.setState({ isHidden: false }); - return; - } - } - } catch { - // ignore and fall through to runtime-store check + private onCellRemoved() { + this.updateVisibility(); + } + + private updateVisibility() { + const kinemages = this.getKinemageList(); + this.setState({ isHidden: kinemages.length === 0 }); + } + + private getKinemageList(): Array<{ kinData: Kinemage, ref: string }> { + const result: Array<{ kinData: Kinemage, ref: string }> = []; + + try { + const cells = (this.plugin.state.data as any).cells as Map; + for (const [ref, entry] of cells) { + const obj = (entry as any).obj; + // Look for Format.Json nodes that contain kinData + if (obj && obj.data && (obj.data as any).kinData) { + result.push({ kinData: (obj.data as any).kinData, ref }); } - // If no state cells remain, also check runtime store (loaded kinemage data) - this.updateVisibility(); + } + } catch (e) { + console.warn('Failed to enumerate kinemage nodes', e); } - private updateVisibility() { - const data = getLoadedKinemageData(); - const has = !!(data && data.kinemages && data.kinemages.length > 0); - this.setState({ isHidden: !has }); - } + return result; + } - private getKinemageList() { - const data = getLoadedKinemageData(); - return (data && data.kinemages) ? data.kinemages : []; + private async applyView(kinData: Kinemage, viewKey: string) { + const snap = (kinData as any).viewSnapshots?.[viewKey]; + if (snap) { + await applyViewSnapshot(this.plugin, snap as Partial); } + } - private async applyView(kin: any, viewKey: string) { - const snap = (kin as any).viewSnapshots?.[viewKey]; - if (snap) { - await applyViewSnapshot(this.plugin, snap as Partial); + private async toggleVisibility(kinData: Kinemage, kinRef: string, target: { type: 'group' | 'subgroup' | 'master', key: string }) { + try { + if (target.type === 'group') { + const g = kinData.groupDict[target.key]; + if (g) g.off = !g.off; + } else if (target.type === 'subgroup') { + const s = kinData.subgroupDict[target.key]; + if (s) s.off = !s.off; + } else { + const m = kinData.masterDict[target.key]; + if (m) m.visible = !m.visible; + } + + // Rebuild shapes for this kinemage using the state ref + await rebuildShapesForKinemage(this.plugin, { ref: kinRef } as any); + this.updateVisibility(); + } catch (e) { + console.error('Failed to toggle kinemage visibility', e); + } + } + + private async triggerAnimateForKin(kinData: Kinemage, kinRef: string, mode: 'animate' | '2animate') { + try { + if (mode === 'animate') { + kinData.activeAnimateGroup = (kinData.activeAnimateGroup + 1) % Math.max(1, kinData.groupsAnimate.length); + + // Make only the active animate group visible, hide the others (if any) + for (let i = 0; i < kinData.groupsAnimate.length; i++) { + const groupName = kinData.groupsAnimate[i]; + const groupInfo = kinData.groupDict[groupName]; + if (groupInfo) { + groupInfo.off = (i !== kinData.activeAnimateGroup); + } } - } + } else { + kinData.activeAnimateGroup2 = (kinData.activeAnimateGroup2 + 1) % Math.max(1, kinData.groupsAnimate2.length); - private async rebuildShapesForKin(kin: any) { - - // Store away the current camera snapshot so we can replace it after rebuilding shapes (which may reset the view). - // We get this from the canvas3d. - const curSnap = (this.plugin.canvas3d && (this.plugin.canvas3d as any).camera && (this.plugin.canvas3d as any).camera.getSnapshot) - ? (this.plugin.canvas3d as any).camera.getSnapshot() - : undefined; - - const update = this.plugin.state.data.build(); - await destroyShapesForKinemage(this.plugin, kin); - await createShapesForKinemage(this.plugin, update, kin); - await update.commit(); - - // restore camera snapshot to avoid the temporary zoom-out caused by removing geometry - if (curSnap) { - try { - await applyViewSnapshot(this.plugin, curSnap); - } catch (e) { - console.warn('Failed to restore camera snapshot after recreating shapes', e); - } + // Make only the active animate group visible, hide the others (if any) + for (let i = 0; i < kinData.groupsAnimate2.length; i++) { + const groupName = kinData.groupsAnimate2[i]; + const groupInfo = kinData.groupDict[groupName]; + if (groupInfo) { + groupInfo.off = (i !== kinData.activeAnimateGroup2); + } } + } + + // Rebuild shapes for this kinemage using the state ref + await rebuildShapesForKinemage(this.plugin, { ref: kinRef } as any); + this.updateVisibility(); + } catch (e) { + console.error('Failed to trigger animate', e); } + } - private async toggleVisibility(kin: any, target: { type: 'group'|'subgroup'|'master', key: string }) { - try { - if (target.type === 'group') { - const g = kin.groupDict[target.key]; - if (g) g.off = !g.off; - } else if (target.type === 'subgroup') { - const s = kin.subgroupDict[target.key]; - if (s) s.off = !s.off; - } else { - const m = kin.masterDict[target.key]; - if (m) m.visible = !m.visible; - } + renderControls() { + const kins = this.getKinemageList(); + if (kins.length === 0) return
No Kinemage data
; - // rebuild shapes for this kinemage - await this.rebuildShapesForKin(kin); - this.updateVisibility(); - } catch (e) { - console.error('Failed to toggle kinemage visibility', e); + const blocks: React.ReactNode[] = []; + for (const { kinData, ref } of kins) { + const title = kinData.pdbfile || nameFromString(kinData.caption) || 'Kinemage'; + const kinBlock: React.ReactNode[] = []; + kinBlock.push(
{title}
); + + // views + for (const [viewKey, viewObj] of Object.entries(kinData.viewDict || {})) { + const label = viewObj.name || `View ${viewKey}`; + kinBlock.push( +
+ + {label} +
+ ); + } + + // animate + if (kinData.groupsAnimate && kinData.groupsAnimate.length > 0) { + kinBlock.push( +
+ + Animate +
+ ); + } + if (kinData.groupsAnimate2 && kinData.groupsAnimate2.length > 0) { + kinBlock.push( +
+ + Animate2 +
+ ); + } + + // groups + for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict || {})) { + if ((groupInfo as any).nobutton) continue; + const visible = !(groupInfo as any).off; + kinBlock.push( +
+ +
+ ); + } + + // subgroups that don't belong to a group (standalone) + for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) { + // if parent group present, those groups' subgroups are already shown when iterating groups + if (subgroupKey.indexOf(':') !== -1) { + // subgroups with parent group; skip here (shown under parent group) + continue; } + if ((subgroupInfo as any).nobutton) continue; + const visible = !(subgroupInfo as any).off; + kinBlock.push( +
+ +
+ ); + } + + // masters + for (const [masterKey, masterInfo] of Object.entries(kinData.masterDict || {})) { + const visible = !!(masterInfo && (masterInfo as any).visible); + kinBlock.push( +
+ +
+ ); + } + + blocks.push(
{kinBlock}
); } - private async triggerAnimateForKin(kin: any, mode: 'animate'|'2animate') { - try { - if (mode === 'animate') { - kin.activeAnimateGroup = (kin.activeAnimateGroup + 1) % Math.max(1, kin.groupsAnimate.length); - - // Make only the active animate group visible, hide the others (if any) - for (let i = 0; i < kin.groupsAnimate.length; i++) { - const groupName = kin.groupsAnimate[i]; - const groupInfo = kin.groupDict[groupName]; - if (groupInfo) { - groupInfo.off = (i !== kin.activeAnimateGroup); - } - } - } else { - kin.activeAnimateGroup2 = (kin.activeAnimateGroup2 + 1) % Math.max(1, kin.groupsAnimate2.length); - - // Make only the active animate group visible, hide the others (if any) - for (let i = 0; i < kin.groupsAnimate2.length; i++) { - const groupName = kin.groupsAnimate2[i]; - const groupInfo = kin.groupDict[groupName]; - if (groupInfo) { - groupInfo.off = (i !== kin.activeAnimateGroup2); - } - } - } - - // rebuild shapes for this kinemage - await this.rebuildShapesForKin(kin); - this.updateVisibility(); - } catch (e) { - console.error('Failed to trigger animate', e); - } - } - - renderControls() { - const kins = this.getKinemageList(); - if (kins.length === 0) return
No Kinemage data
; - - const blocks: React.ReactNode[] = []; - for (const kin of kins) { - const title = kin.pdbfile || nameFromString(kin.caption) || 'Kinemage'; - const kinBlock: React.ReactNode[] = []; - kinBlock.push(
{title}
); - - // views - for (const [viewKey, viewObj] of Object.entries(kin.viewDict || {})) { - const label = viewObj.name || `View ${viewKey}`; - kinBlock.push( -
- - {label} -
- ); - } - - // animate - if (kin.groupsAnimate && kin.groupsAnimate.length > 0) { - kinBlock.push( -
- - Animate -
- ); - } - if (kin.groupsAnimate2 && kin.groupsAnimate2.length > 0) { - kinBlock.push( -
- - Animate2 -
- ); - } - - // groups - for (const [groupKey, groupInfo] of Object.entries(kin.groupDict || {})) { - if ((groupInfo as any).nobutton) continue; - const visible = !(groupInfo as any).off; - kinBlock.push( -
- -
- ); - } - - // subgroups that don't belong to a group (standalone) - for (const [subgroupKey, subgroupInfo] of Object.entries(kin.subgroupDict || {})) { - // if parent group present, those groups' subgroups are already shown when iterating groups - if (subgroupKey.indexOf(':') !== -1) { - // subgroups with parent group; skip here (shown under parent group) - continue; - } - if ((subgroupInfo as any).nobutton) continue; - const visible = !(subgroupInfo as any).off; - kinBlock.push( -
- -
- ); - } - - // masters - for (const [masterKey, masterInfo] of Object.entries(kin.masterDict || {})) { - const visible = !!(masterInfo && (masterInfo as any).visible); - kinBlock.push( -
- -
- ); - } - - blocks.push(
{kinBlock}
); - } - - return
{blocks}
; - } -} + return
{blocks}
; + } +} \ No newline at end of file