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
3 changes: 1 addition & 2 deletions src/chunk_manager/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ export const PREFETCH_PRIORITY_MULTIPLIER = 1e13;
export const CHUNK_QUEUE_MANAGER_RPC_ID = "ChunkQueueManager";
export const CHUNK_MANAGER_RPC_ID = "ChunkManager";
export const CHUNK_SOURCE_INVALIDATE_RPC_ID = "ChunkSource.invalidate";
export const CHUNK_SOURCE_INVALIDATE_KEYS_RPC_ID =
"ChunkSource.invalidateKeys";
export const CHUNK_SOURCE_INVALIDATE_KEYS_RPC_ID = "ChunkSource.invalidateKeys";

export const REQUEST_CHUNK_STATISTICS_RPC_ID =
"ChunkQueueManager.requestChunkStatistics";
Expand Down
6 changes: 5 additions & 1 deletion src/chunk_manager/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,11 @@ export class ChunkQueueManager extends SharedObject {
}

function updateChunk(rpc: RPC, x: any) {
const source: ChunkSource = rpc.get(x.source);
const source = rpc.get(x.source) as ChunkSource | undefined;
if (source === undefined) {
// Source was removed while chunk update was in flight.
return;
}
if (DEBUG_CHUNK_UPDATES) {
console.log(
`${Date.now()} Chunk.update received: ` +
Expand Down
18 changes: 15 additions & 3 deletions src/datasource/catmaid/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,18 @@ describe("CatmaidClient skeleton editing methods", () => {
{ nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" },
],
}),
).resolves.toEqual({});
).resolves.toEqual({
nodeSourceStateUpdates: [
{
nodeId: 202,
sourceState: testSourceState("2026-03-29T12:08:00Z"),
},
{
nodeId: 201,
sourceState: testSourceState("2026-03-29T12:08:00Z"),
},
],
});

expect(fetchMock).toHaveBeenCalledTimes(1);
const requestBody = getFetchBody(fetchMock);
Expand Down Expand Up @@ -1055,7 +1066,6 @@ describe("CatmaidClient skeleton editing methods", () => {
const fetchMock = vi.fn().mockResolvedValue({
newroot: 202,
skeleton_id: 17,
edition_time: "2026-03-29T12:08:00Z",
});
(client as any).fetchProjectEndpoint = fetchMock;

Expand All @@ -1075,7 +1085,9 @@ describe("CatmaidClient skeleton editing methods", () => {
{ nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" },
],
}),
).resolves.toEqual({});
).rejects.toThrow(
"CATMAID skeleton/reroot did not return the new root edition_time.",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

Expand Down
3 changes: 3 additions & 0 deletions src/datasource/catmaid/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ export class CatmaidDataSourceProvider implements DataSourceProvider {
id: "skeletons-chunked",
default: true,
subsource: { mesh: multiscaleSource },
layerRuntimeStateDisposal: {
kind: "spatiallyIndexedSkeleton",
},
},
{
id: "skeletons",
Expand Down
10 changes: 0 additions & 10 deletions src/datasource/catmaid/spatial_skeleton_commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,15 +450,6 @@ function requireCatmaidMergeCommandPayload(payload: object) {
);
}

function validateCatmaidNodeDescription(description: string | undefined) {
if (description === undefined) return;
for (const line of description.split(/\r?\n/)) {
if (line.trim().includes(",")) {
throw new Error("Node descriptions containing commas are not supported.");
}
}
}

function cloneNodeSnapshot(
node: SpatiallyIndexedSkeletonNode,
): SpatiallyIndexedSkeletonNode {
Expand Down Expand Up @@ -4314,7 +4305,6 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand {
nextDescription: string | undefined,
statusPrefix: string,
) {
validateCatmaidNodeDescription(nextDescription);
const { node } = await getResolvedNodeForEdit(
this.layer,
this.stableNodeId,
Expand Down
10 changes: 10 additions & 0 deletions src/datasource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export interface CompleteUrlOptions extends CompleteUrlOptionsBase {
signal: AbortSignal;
}

export interface LayerRuntimeStateDisposalRequest {
kind: string;
}

export interface DataSubsourceEntry {
/**
* Unique identifier (within the group) for this subsource. Stored in the JSON state
Expand Down Expand Up @@ -182,6 +186,12 @@ export interface DataSubsourceEntry {
* Specifies whether this associated data source is enabled by default.
*/
default: boolean;

/**
* Optional layer-owned runtime cleanup requested when this active subsource's
* datasource is replaced or cleared.
*/
layerRuntimeStateDisposal?: LayerRuntimeStateDisposalRequest;
}

export interface ChannelMetadata {
Expand Down
13 changes: 12 additions & 1 deletion src/layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ import type {
} from "#src/datasource/index.js";
import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js";
import type { DisplayContext, RenderedPanel } from "#src/display_context.js";
import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js";
import type {
LayerDataSourceChangeRuntimeDisposalContext,
LoadedDataSubsource,
} from "#src/layer/layer_data_source.js";
import {
LayerDataSource,
layerDataSourceSpecificationFromJson,
Expand Down Expand Up @@ -431,6 +434,14 @@ export class UserLayer extends RefCounted {
subsources;
}

// Derived classes may override to clear layer-owned runtime state for active
// datasources that explicitly request cleanup on source change.
disposeLayerRuntimeStateForDataSourceChange(
_context: LayerDataSourceChangeRuntimeDisposalContext,
) {
return false;
}

updateDataSubsourceActivations() {
function* getDataSubsources(
this: UserLayer,
Expand Down
49 changes: 46 additions & 3 deletions src/layer/layer_data_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
DataSourceWithRedirectInfo,
DataSubsourceEntry,
DataSubsourceSpecification,
LayerRuntimeStateDisposalRequest,
} from "#src/datasource/index.js";
import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js";
import type { UserLayer } from "#src/layer/index.js";
Expand Down Expand Up @@ -148,6 +149,7 @@ export class LoadedDataSubsource {
enabled: boolean;
activated: RefCounted | undefined = undefined;
guardValues: any[] = [];
renderLayers = new Set<RenderLayer>();
messages = new MessageList();
isActiveChanged = new NullarySignal();
constructor(
Expand Down Expand Up @@ -212,9 +214,13 @@ export class LoadedDataSubsource {

addRenderLayer(renderLayer: Owned<RenderLayer>) {
const activated = this.activated!;
activated.registerDisposer(
this.loadedDataSource.layer.addRenderLayer(renderLayer),
);
const removeRenderLayer =
this.loadedDataSource.layer.addRenderLayer(renderLayer);
this.renderLayers.add(renderLayer);
activated.registerDisposer(() => {
this.renderLayers.delete(renderLayer);
removeRenderLayer();
});
activated.registerDisposer(this.messages.addChild(renderLayer.messages));
}

Expand Down Expand Up @@ -301,6 +307,16 @@ export class LoadedLayerDataSource extends RefCounted {
}
}

export type LayerDataSourceChangeReason = "replace" | "clear";

export interface LayerDataSourceChangeRuntimeDisposalContext {
request: LayerRuntimeStateDisposalRequest;
reason: LayerDataSourceChangeReason;
layerDataSource: LayerDataSource;
loadedDataSource: LoadedLayerDataSource;
loadedSubsource: LoadedDataSubsource;
}

export type LayerDataSourceLoadState =
| {
error: Error;
Expand Down Expand Up @@ -368,10 +384,36 @@ export class LayerDataSource extends RefCounted {
return this.loadState_;
}

private disposeRuntimeStateForDataSourceChange(
reason: LayerDataSourceChangeReason,
) {
const { loadState } = this;
if (loadState === undefined || loadState.error !== undefined) return false;
const handledRequestKinds = new Set<string>();
let changed = false;
for (const loadedSubsource of loadState.subsources) {
if (loadedSubsource.activated === undefined) continue;
const request = loadedSubsource.subsourceEntry.layerRuntimeStateDisposal;
if (request === undefined) continue;
if (handledRequestKinds.has(request.kind)) continue;
handledRequestKinds.add(request.kind);
changed =
this.layer.disposeLayerRuntimeStateForDataSourceChange({
request,
reason,
layerDataSource: this,
loadedDataSource: loadState,
loadedSubsource,
}) || changed;
}
return changed;
}

set spec(spec: DataSourceSpecification) {
const { layer } = this;
this.messages.clearMessages();
if (spec.url.length === 0) {
this.disposeRuntimeStateForDataSourceChange("clear");
if (layer.dataSources.length !== 1) {
const index = layer.dataSources.indexOf(this);
if (index !== -1) {
Expand All @@ -395,6 +437,7 @@ export class LayerDataSource extends RefCounted {
disposableOnce(layer.markLoading()),
);
if (this.refCounted_ !== undefined) {
this.disposeRuntimeStateForDataSourceChange("replace");
this.refCounted_.dispose();
this.loadState_ = undefined;
}
Expand Down
59 changes: 51 additions & 8 deletions src/layer/segmentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ import {
registerVolumeLayerType,
UserLayer,
} from "#src/layer/index.js";
import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js";
import type {
LayerDataSourceChangeRuntimeDisposalContext,
LoadedDataSubsource,
} from "#src/layer/layer_data_source.js";
import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js";
import * as json_keys from "#src/layer/segmentation/json_keys.js";
import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js";
Expand Down Expand Up @@ -104,6 +107,7 @@ import {
SKELETON_GO_ROOT,
SKELETON_GO_UNFINISHED,
SKELETON_REDO,
SKELETON_TOGGLE_HIDDEN,
SKELETON_UNDO,
} from "#src/skeleton/actions.js";
import type {
Expand Down Expand Up @@ -812,6 +816,9 @@ function copyOptionalSpatialSkeletonPosition(
return new Float32Array(Array.from(value, Number));
}

const SPATIALLY_INDEXED_SKELETON_RUNTIME_DISPOSAL_KIND =
"spatiallyIndexedSkeleton";

const Base = UserLayerWithAnnotationsMixin(UserLayer);
export class SegmentationUserLayer extends Base {
sliceViewRenderScaleHistogram = new RenderScaleHistogram();
Expand Down Expand Up @@ -1095,6 +1102,8 @@ export class SegmentationUserLayer extends Base {
x === undefined ? undefined : parseUint64(x),
);

private savedHiddenObjectAlpha: number | undefined;

constructor(managedLayer: Borrowed<ManagedUserLayer>) {
super(managedLayer);
this.codeVisible.changed.add(this.specificationChanged.dispatch);
Expand Down Expand Up @@ -1597,6 +1606,31 @@ export class SegmentationUserLayer extends Base {
this.spatialSkeletonState.markNodeDataChanged(options);
}

disposeLayerRuntimeStateForDataSourceChange(
context: LayerDataSourceChangeRuntimeDisposalContext,
) {
if (
context.request.kind !== SPATIALLY_INDEXED_SKELETON_RUNTIME_DISPOSAL_KIND
) {
return super.disposeLayerRuntimeStateForDataSourceChange(context);
}
let changed = false;
const spatialSkeletonLayers = new Set<SpatiallyIndexedSkeletonLayer>();
for (const renderLayer of context.loadedSubsource.renderLayers) {
if (
renderLayer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer ||
renderLayer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer
) {
spatialSkeletonLayers.add(renderLayer.base);
}
}
for (const spatialSkeletonLayer of spatialSkeletonLayers) {
changed = spatialSkeletonLayer.disposeRuntimeState() || changed;
}
changed = this.spatialSkeletonState.clearRuntimeState() || changed;
return changed;
}

activateDataSubsources(subsources: Iterable<LoadedDataSubsource>) {
const updatedSegmentPropertyMaps: SegmentPropertyMap[] = [];
const isGroupRoot =
Expand Down Expand Up @@ -2100,6 +2134,17 @@ export class SegmentationUserLayer extends Base {
}
break;
}
case SKELETON_TOGGLE_HIDDEN: {
const { hiddenObjectAlpha } = this.displayState;
if (this.savedHiddenObjectAlpha !== undefined) {
hiddenObjectAlpha.value = this.savedHiddenObjectAlpha;
this.savedHiddenObjectAlpha = undefined;
} else {
this.savedHiddenObjectAlpha = hiddenObjectAlpha.value;
hiddenObjectAlpha.value = 0;
}
break;
}
case SKELETON_GO_ROOT:
case SKELETON_GO_BRANCH_START:
case SKELETON_GO_BRANCH_END:
Expand Down Expand Up @@ -2676,13 +2721,11 @@ export class SegmentationUserLayer extends Base {
? svg_circle
: nodeType === undefined
? svg_circle
: nodeType === undefined
? svg_circle
: SPATIAL_SKELETON_NODE_TYPE_ICONS[nodeType],
title: nodeTypeIconTitle,
clickable: false,
}),
);
: SPATIAL_SKELETON_NODE_TYPE_ICONS[nodeType],
title: nodeTypeIconTitle,
clickable: false,
});
icon.appendChild(nodeTypeIcon);
summaryRow.appendChild(icon);

const skeletonDisplayTransform =
Expand Down
3 changes: 3 additions & 0 deletions src/skeleton/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ export const SKELETON_ENTER_CREATE = "skeleton-enter-create";
export const SKELETON_PIN_NODE = "skeleton-pin-node";
export const SKELETON_ENTER_DELETE_MODE = "skeleton-enter-delete-mode";
export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection";

// --- Display toggles ---
export const SKELETON_TOGGLE_HIDDEN = "skeleton-toggle-hidden";
9 changes: 9 additions & 0 deletions src/skeleton/command_history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export class SpatialSkeletonCommandMappings {
private nodeIdMappings = new Map<number, number>();
private segmentIdMappings = new Map<number, number>();

get empty() {
return this.nodeIdMappings.size === 0 && this.segmentIdMappings.size === 0;
}

clear() {
this.nodeIdMappings.clear();
this.segmentIdMappings.clear();
Expand Down Expand Up @@ -241,10 +245,15 @@ export class SpatialSkeletonCommandHistory extends RefCounted {
}

clear() {
const changed =
this.undoEntries.length !== 0 ||
this.redoEntries.length !== 0 ||
!this.mappings.empty;
this.undoEntries = [];
this.redoEntries = [];
this.mappings.clear();
this.updateState();
return changed;
}

setSource(source: unknown) {
Expand Down
Loading