From 77b30edcb7410594050343a8ca1d97a4d700c414 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 00:12:26 +0200 Subject: [PATCH 01/13] feat: update hovered node update logic so node ID + seg ID is there --- src/layer/segmentation/index.ts | 6 ++-- src/layer/segmentation/selection.ts | 53 +++++++++++++++++------------ 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index f135e34d0b..5ef75d3f13 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -799,7 +799,7 @@ export class SegmentationUserLayer extends Base { readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< SelectedSpatialSkeletonNodeInfo | undefined >(undefined); - readonly hoveredSpatialSkeletonNodeId = this.registerDisposer( + readonly hoveredSpatialSkeletonNodeInfo = this.registerDisposer( new SpatialSkeletonHoverState(), ); readonly spatialSkeletonVisibleChunksNeeded = new WatchableValue(0); @@ -1118,7 +1118,7 @@ export class SegmentationUserLayer extends Base { ), ); syncSelectedSpatialSkeletonNodeIdFromGlobalSelection(); - this.hoveredSpatialSkeletonNodeId.bindTo( + this.hoveredSpatialSkeletonNodeInfo.bindTo( this.manager.layerSelectedValues, this, ); @@ -1603,6 +1603,7 @@ export class SegmentationUserLayer extends Base { { sources2d: slicePanelSources, selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => @@ -1636,6 +1637,7 @@ export class SegmentationUserLayer extends Base { displayState, { selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index 2d15b10751..c843b0635d 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import type { LayerSelectedValues } from "#src/layer/index.js"; +import type { + LayerSelectedValues, + PickedSpatialSkeletonState, +} from "#src/layer/index.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { RefCounted } from "#src/util/disposable.js"; import { parseUint64 } from "#src/util/json.js"; @@ -23,11 +26,12 @@ import { NullarySignal } from "#src/util/signal.js"; interface SpatialSkeletonViewerHoverMouseStateLike { active: boolean; pickedRenderLayer: TRenderLayer | null | undefined; - pickedSpatialSkeleton?: - | { - nodeId?: unknown; - } - | undefined; + pickedSpatialSkeleton?: PickedSpatialSkeletonState; +} + +export interface SpatialSkeletonHoverInfo { + readonly nodeId: number; + readonly segmentId?: number; } interface SpatialSkeletonViewerHoverLayerLike { @@ -88,12 +92,6 @@ function getSelectionValueIdString(value: unknown) { } } -function normalizeSpatialSkeletonViewerHoverNodeId(value: unknown) { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 - ? value - : undefined; -} - export function getNodeIdFromLayerSelectionState( state: { nodeId?: unknown; value?: unknown } | undefined, ) { @@ -175,10 +173,10 @@ export function getNodeIdFromViewerSelection( ); } -function getSpatialSkeletonNodeIdFromViewerHover( +function getSpatialSkeletonHoverInfoFromViewerHover( mouseState: SpatialSkeletonViewerHoverMouseStateLike, layer: SpatialSkeletonViewerHoverLayerLike, -) { +): SpatialSkeletonHoverInfo | undefined { if (!mouseState.active) return undefined; const pickedRenderLayer = mouseState.pickedRenderLayer; if (pickedRenderLayer !== null) { @@ -189,18 +187,29 @@ function getSpatialSkeletonNodeIdFromViewerHover( return undefined; } } - // TODO (SKM): I think we can inline this function - return normalizeSpatialSkeletonViewerHoverNodeId( - mouseState.pickedSpatialSkeleton?.nodeId, - ); + const pickedSpatialSkeleton = mouseState.pickedSpatialSkeleton; + const nodeId = pickedSpatialSkeleton?.nodeId; + if (nodeId === undefined) return undefined; + const segmentId = pickedSpatialSkeleton?.segmentId; + if (segmentId === undefined) return undefined; + return segmentId === undefined ? { nodeId } : { nodeId, segmentId }; +} + +function spatialSkeletonHoverInfoEqual( + a: SpatialSkeletonHoverInfo | undefined, + b: SpatialSkeletonHoverInfo | undefined, +) { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.nodeId === b.nodeId && a.segmentId === b.segmentId; } export class SpatialSkeletonHoverState extends RefCounted { - value: number | undefined = undefined; + value: SpatialSkeletonHoverInfo | undefined = undefined; readonly changed = new NullarySignal(); - setValue(value: number | undefined) { - if (this.value !== value) { + setValue(value: SpatialSkeletonHoverInfo | undefined) { + if (!spatialSkeletonHoverInfoEqual(this.value, value)) { this.value = value; this.changed.dispatch(); } @@ -213,7 +222,7 @@ export class SpatialSkeletonHoverState extends RefCounted { this.registerDisposer( layerSelectedValues.changed.add(() => { this.setValue( - getSpatialSkeletonNodeIdFromViewerHover( + getSpatialSkeletonHoverInfoFromViewerHover( layerSelectedValues.mouseState, layer, ), From ebb97e3ed433bf3171bef2a1e35b5df6e12845d1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 00:13:00 +0200 Subject: [PATCH 02/13] feat: thread highlighted node ID through FE --- src/layer/segmentation/selection.spec.ts | 12 +- src/skeleton/frontend.spec.ts | 124 ++++++++++++++--- src/skeleton/frontend.ts | 166 ++++++++++++++++++----- src/ui/skeleton_tab.ts | 4 +- 4 files changed, 250 insertions(+), 56 deletions(-) diff --git a/src/layer/segmentation/selection.spec.ts b/src/layer/segmentation/selection.spec.ts index 34f7228aa9..4d28fc209f 100644 --- a/src/layer/segmentation/selection.spec.ts +++ b/src/layer/segmentation/selection.spec.ts @@ -144,7 +144,7 @@ describe("layer/segmentation/selection", () => { let mouseState: { active: boolean; pickedRenderLayer: unknown; - pickedSpatialSkeleton?: { nodeId?: unknown }; + pickedSpatialSkeleton?: { nodeId?: unknown; segmentId?: unknown }; } = { active: false, pickedRenderLayer: null, @@ -175,7 +175,15 @@ describe("layer/segmentation/selection", () => { pickedSpatialSkeleton: { nodeId: 31 }, }; trigger(); - expect(hoverState.value).toBe(31); + expect(hoverState.value).toEqual({ nodeId: 31 }); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: 31, segmentId: 7 }, + }; + trigger(); + expect(hoverState.value).toEqual({ nodeId: 31, segmentId: 7 }); mouseState = { active: true, diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index b19d500be3..9d9c26f623 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -134,22 +134,23 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { - selectedNodeId: { value: 101 }, + selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - const outlineColor = (layer as any).getSelectedNodeOutlineColor(); - const cachedOutlineColor = (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + const outlineColor = (layer as any).selectedNodeOutlineColor; + (layer as any).updateNodeOutlineColorPair(); + const cachedOutlineColor = (layer as any).selectedNodeOutlineColor; expect(isSelected).not.toHaveBeenCalled(); expect(cachedOutlineColor).toBe(outlineColor); - expect(outlineColor[0]).toBeCloseTo(1); - expect(outlineColor[1]).toBeCloseTo(0.95); - expect(outlineColor[2]).toBeCloseTo(0.35); + // The outline color is chosen for high contrast against the segment color. expect(getContrastRatio(outlineColor, sourceColor)).toBeGreaterThanOrEqual( 3, ); @@ -183,16 +184,17 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); selectedNodeId.value = 202; - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); @@ -224,18 +226,104 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); + + it("derives the hovered-node outline color from the hovered segment when nothing is selected", () => { + const sourceColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map(), + segmentDefaultColor: { value: sourceColor }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: undefined }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // The hovered outline is chosen for high contrast against its own (white) + // segment color. + expect( + getContrastRatio(highlightedColor, sourceColor), + ).toBeGreaterThanOrEqual(3); + }); + + it("derives each outline from its own segment when selected and hovered nodes belong to different segments", () => { + // Selected node on a dark segment, hovered node on a bright segment, as + // happens when hovering a merge target on a differently colored skeleton. + const selectedSegmentColor = vec3.fromValues(0, 0, 0); + const hoveredSegmentColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map([ + [101n, 0x000000n], + [202n, 0xffffffn], + ]), + segmentDefaultColor: { value: undefined }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: { nodeId: 101, segmentId: 101 } }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const selectedColor = (layer as any).selectedNodeOutlineColor; + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // Each outline contrasts against its own segment color... + expect( + getContrastRatio(selectedColor, selectedSegmentColor), + ).toBeGreaterThanOrEqual(3); + expect( + getContrastRatio(highlightedColor, hoveredSegmentColor), + ).toBeGreaterThanOrEqual(3); + // ...and the two outlines are different colors. + expect([...selectedColor]).not.toEqual([...highlightedColor]); + }); }); describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 4008459ab1..787c096ba8 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,7 +117,10 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { computeHighVisibilityContrastColor } from "#src/util/color.js"; +import { + computeDistinctHighVisibilityContrastColor, + computeHighVisibilityContrastColor, +} from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -196,10 +199,14 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { `; const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "1.75"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "3.0"; -const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "1.5"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "2.5"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "3.5"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "8.0"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "3.0"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; +// Fraction of the node diameter used as the highlight outline width before +// clamping to the min/max above. Nodes are small (~5-6px), so this mostly hits +// the min for typical nodes and scales up the ring for larger nodes. +const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; @@ -764,13 +771,16 @@ void emitDefault() { builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); builder.addUniform("highp int", "uSelectedNodeId"); builder.addVarying("highp float", "vSelectedNode", "flat"); + builder.addUniform("highp vec3", "uHighlightedNodeOutlineColor"); + builder.addUniform("highp int", "uHighlightedNodeId"); + builder.addVarying("highp float", "vHighlightedNode", "flat"); const selectedOutlineMinWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D; const selectedOutlineMaxWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; - selectedOutlineWidthExpression = `(vSelectedNode * clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; + selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${SELECTED_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -783,6 +793,7 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); } if (this.nodeIdAttributeIndex !== undefined) { vertexMain += `vSelectedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uSelectedNodeId);\n`; + vertexMain += `vHighlightedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uHighlightedNodeId);\n`; } if ( skeletonParams.dynamicSegmentAppearance && @@ -807,8 +818,10 @@ emitCircle( // getSegmentAppearance(). uColor is unused in this path. const segmentExpression = `vSegmentValue`; const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -853,8 +866,10 @@ void emitDefault() { // Per-vertex color attribute path: color comes from a per-vertex // attribute; alpha is taken from the attribute's alpha component. const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -1844,6 +1859,9 @@ interface SpatiallyIndexedSkeletonLayerOptions { selectedNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; + hoveredNodeInfo?: WatchableValueInterface< + SelectedSkeletonNodeInfo | undefined + >; pendingNodePositionVersion?: WatchableValueInterface; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; @@ -2073,6 +2091,9 @@ export class SpatiallyIndexedSkeletonLayer private selectedNodeInfo: | WatchableValueInterface | undefined; + private hoveredNodeInfo: + | WatchableValueInterface + | undefined; private pendingNodePositionVersion: | WatchableValueInterface | undefined; @@ -2096,8 +2117,13 @@ export class SpatiallyIndexedSkeletonLayer private readonly selectedNodeOutlineColor = vec3.clone( SELECTED_NODE_OUTLINE_FALLBACK_COLOR, ); - private selectedNodeOutlineColorGeneration = 0; - private cachedSelectedNodeOutlineColorGeneration = -1; + private readonly highlightedNodeOutlineColor = vec3.clone( + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); + // The selected and hovered outline colors are derived together from a single + // source segment color, so they share one cache generation. + private nodeOutlineColorGeneration = 0; + private cachedNodeOutlineColorGeneration = -1; private disposeOverlayChunk() { this.overlayChunk?.dispose(this.gl); @@ -2150,27 +2176,70 @@ export class SpatiallyIndexedSkeletonLayer return segmentIds; } - private getSelectedNodeOutlineColor() { - const nodeInfo = this.selectedNodeInfo?.value; - if (nodeInfo === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; - } - const currentGeneration = this.selectedNodeOutlineColorGeneration; - if (this.cachedSelectedNodeOutlineColorGeneration === currentGeneration) { - return this.selectedNodeOutlineColor; - } + // Segment fill color a node's outline should contrast against, or undefined + // when no segment can be resolved. Falls back to the currently selected + // segment when the node carries no segment id. + private getNodeSegmentColor( + nodeInfo: SelectedSkeletonNodeInfo, + ): Float32Array | undefined { const segmentId = nodeInfo.segmentId !== undefined ? BigInt(nodeInfo.segmentId) : this.displayState.segmentSelectionState.baseValue; if (segmentId === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; + return undefined; + } + return getBaseObjectColor(this.displayState, segmentId); + } + + // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in + // place. Each outline is chosen for high contrast against its own node's + // segment color, and the hovered outline is additionally kept visually + // distinct from the selected outline so both rings are tellable apart even + // when the two nodes share a segment (e.g. during a merge). + private updateNodeOutlineColorPair() { + const currentGeneration = this.nodeOutlineColorGeneration; + if (this.cachedNodeOutlineColorGeneration === currentGeneration) { + return; + } + this.cachedNodeOutlineColorGeneration = currentGeneration; + + const selectedNodeInfo = this.selectedNodeInfo?.value; + const selectedSegmentColor = + selectedNodeInfo !== undefined + ? this.getNodeSegmentColor(selectedNodeInfo) + : undefined; + if (selectedSegmentColor !== undefined) { + computeHighVisibilityContrastColor( + this.selectedNodeOutlineColor, + selectedSegmentColor, + ); + } else { + vec3.copy( + this.selectedNodeOutlineColor, + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); + } + + const hoveredNodeInfo = this.hoveredNodeInfo?.value; + const hoveredSegmentColor = + hoveredNodeInfo !== undefined + ? this.getNodeSegmentColor(hoveredNodeInfo) + : undefined; + if (hoveredSegmentColor !== undefined) { + computeDistinctHighVisibilityContrastColor( + this.highlightedNodeOutlineColor, + hoveredSegmentColor, + selectedSegmentColor !== undefined + ? this.selectedNodeOutlineColor + : undefined, + ); + } else { + vec3.copy( + this.highlightedNodeOutlineColor, + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); } - this.cachedSelectedNodeOutlineColorGeneration = currentGeneration; - return computeHighVisibilityContrastColor( - this.selectedNodeOutlineColor, - getBaseObjectColor(this.displayState, segmentId), - ); } getRetainedOverlaySegmentIds() { @@ -2372,6 +2441,7 @@ export class SpatiallyIndexedSkeletonLayer ), ); this.selectedNodeInfo = options.selectedNodeInfo; + this.hoveredNodeInfo = options.hoveredNodeInfo; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; this.getCachedNodeInfo = options.getCachedNode; @@ -2384,8 +2454,8 @@ export class SpatiallyIndexedSkeletonLayer ), ); registerRedrawWhenSegmentationDisplayState3DChanged(displayState, this); - const invalidateSelectedNodeOutlineColor = () => { - ++this.selectedNodeOutlineColorGeneration; + const invalidateNodeOutlineColors = () => { + ++this.nodeOutlineColorGeneration; }; this.displayState.shaderError.value = undefined; const { skeletonRenderingOptions: renderingOptions } = displayState; @@ -2437,17 +2507,17 @@ export class SpatiallyIndexedSkeletonLayer registerNested((context, colorGroupState) => { context.registerDisposer( colorGroupState.segmentColorHash.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentStatedColors.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentDefaultColor.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); }, this.displayState.segmentationColorGroupState), @@ -2482,7 +2552,17 @@ export class SpatiallyIndexedSkeletonLayer if (this.selectedNodeInfo?.changed) { this.registerDisposer( this.selectedNodeInfo.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); + requestRedraw(); + }), + ); + } + if (this.hoveredNodeInfo?.changed) { + this.registerDisposer( + this.hoveredNodeInfo.changed.add(() => { + // The hovered node drives both which node is outlined and the source + // segment color of its outline. + invalidateNodeOutlineColors(); requestRedraw(); }), ); @@ -2497,7 +2577,7 @@ export class SpatiallyIndexedSkeletonLayer if (inspectionState !== undefined) { this.registerDisposer( inspectionState.nodeDataVersion.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); this.redrawNeeded.dispatch(); }), ); @@ -2933,14 +3013,23 @@ export class SpatiallyIndexedSkeletonLayer if (drawNodes) { nodeShader.bind(); + this.updateNodeOutlineColorPair(); gl.uniform3fv( nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), + this.selectedNodeOutlineColor, ); gl.uniform1i( nodeShader.uniform("uSelectedNodeId"), this.selectedNodeInfo?.value?.nodeId ?? -1, ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); } const chunkOrigin = vec3.create(); @@ -3073,14 +3162,23 @@ export class SpatiallyIndexedSkeletonLayer if (drawNodes) { nodeShader.bind(); + this.updateNodeOutlineColorPair(); gl.uniform3fv( nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), + this.selectedNodeOutlineColor, ); gl.uniform1i( nodeShader.uniform("uSelectedNodeId"), this.selectedNodeInfo?.value?.nodeId ?? -1, ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); } if (renderContext.emitPickID) { diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 005b0291fb..7a49f32033 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -511,7 +511,7 @@ export class SpatialSkeletonEditTab extends Tab { layer.getSpatialSkeletonNodeDisplayDescription(node); const getHoveredNodeIdFromViewer = () => { - return layer.hoveredSpatialSkeletonNodeId.value; + return layer.hoveredSpatialSkeletonNodeInfo.value?.nodeId; }; const getSelectedSegmentId = () => { @@ -1723,7 +1723,7 @@ export class SpatialSkeletonEditTab extends Tab { }), ); this.registerDisposer( - layer.hoveredSpatialSkeletonNodeId.changed.add(() => { + layer.hoveredSpatialSkeletonNodeInfo.changed.add(() => { updateHoveredViewerNode(); }), ); From 14a774667817f0dd3dde27dacf80cd7dc3624002 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 00:25:21 +0200 Subject: [PATCH 03/13] feat: add two palettes, one hovered, one selected --- src/skeleton/frontend.ts | 20 ++--- src/util/color.browser_test.ts | 153 +++++++++++++++++---------------- src/util/color.ts | 83 +++++++++++++++--- 3 files changed, 156 insertions(+), 100 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 787c096ba8..11ccea656f 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -118,8 +118,8 @@ import { import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; import { - computeDistinctHighVisibilityContrastColor, - computeHighVisibilityContrastColor, + computeHoveredNodeHighlightColor, + computeSelectedNodeHighlightColor, } from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; @@ -2193,10 +2193,11 @@ export class SpatiallyIndexedSkeletonLayer } // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in - // place. Each outline is chosen for high contrast against its own node's - // segment color, and the hovered outline is additionally kept visually - // distinct from the selected outline so both rings are tellable apart even - // when the two nodes share a segment (e.g. during a merge). + // place. Each outline is chosen, independently of the other, for high contrast + // against its own node's segment color: the selected node uses the muted + // palette and the hovered node the vivid palette. Because the two are computed + // independently, a given segment color always yields the same selected color + // and the same hovered color. private updateNodeOutlineColorPair() { const currentGeneration = this.nodeOutlineColorGeneration; if (this.cachedNodeOutlineColorGeneration === currentGeneration) { @@ -2210,7 +2211,7 @@ export class SpatiallyIndexedSkeletonLayer ? this.getNodeSegmentColor(selectedNodeInfo) : undefined; if (selectedSegmentColor !== undefined) { - computeHighVisibilityContrastColor( + computeSelectedNodeHighlightColor( this.selectedNodeOutlineColor, selectedSegmentColor, ); @@ -2227,12 +2228,9 @@ export class SpatiallyIndexedSkeletonLayer ? this.getNodeSegmentColor(hoveredNodeInfo) : undefined; if (hoveredSegmentColor !== undefined) { - computeDistinctHighVisibilityContrastColor( + computeHoveredNodeHighlightColor( this.highlightedNodeOutlineColor, hoveredSegmentColor, - selectedSegmentColor !== undefined - ? this.selectedNodeOutlineColor - : undefined, ); } else { vec3.copy( diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts index 0f1c32d9f2..a0e937c0da 100644 --- a/src/util/color.browser_test.ts +++ b/src/util/color.browser_test.ts @@ -16,7 +16,8 @@ import { describe, it, expect } from "vitest"; import { - computeHighVisibilityContrastColor, + computeHoveredNodeHighlightColor, + computeSelectedNodeHighlightColor, getContrastRatio, parseColorSerialization, parseRGBColorSpecification, @@ -115,88 +116,90 @@ describe("getContrastRatio", () => { }); }); -describe("computeHighVisibilityContrastColor", () => { - it("prefers yellow for dark colors", () => { +const REPRESENTATIVE_SEGMENT_COLORS: [number, number, number][] = [ + [0, 0, 0], // black + [1, 1, 1], // white + [0.5, 0.5, 0.5], // gray + [1, 0, 0], // red + [0, 1, 0], // green + [0, 0, 1], // blue + [1, 1, 0], // yellow + [0, 1, 1], // cyan + [1, 0, 1], // magenta + [1, 0.55, 0], // orange +]; + +describe("computeHoveredNodeHighlightColor", () => { + it("picks white for dark segments", () => { const sourceColor = vec3.fromValues(0, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses red for bright colors", () => { - const sourceColor = vec3.fromValues(1, 1, 1); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for red segment colors", () => { - const sourceColor = vec3.fromValues(1, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for low-saturation midtone colors", () => { - const sourceColor = vec3.fromValues(0.5, 0.5, 0.5); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); + const color = computeHoveredNodeHighlightColor(vec3.create(), sourceColor); + expectColorClose(color, [1, 1, 1]); + expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(7); }); - it("uses yellow for near-black colors", () => { - const sourceColor = vec3.fromValues(0.05, 0.05, 0.05); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); + it("is fully determined by the segment color alone", () => { + for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { + const sourceColor = vec3.fromValues(...channels); + const first = computeHoveredNodeHighlightColor( + vec3.create(), + sourceColor, + ); + const second = computeHoveredNodeHighlightColor( + vec3.create(), + sourceColor, + ); + expect([...first]).toEqual([...second]); + } }); +}); - it("uses red for near-white colors", () => { - const sourceColor = vec3.fromValues(0.95, 0.95, 0.95); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); +describe("computeSelectedNodeHighlightColor", () => { + it("is fully determined by the segment color alone", () => { + for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { + const sourceColor = vec3.fromValues(...channels); + const first = computeSelectedNodeHighlightColor( + vec3.create(), + sourceColor, + ); + const second = computeSelectedNodeHighlightColor( + vec3.create(), + sourceColor, + ); + expect([...first]).toEqual([...second]); + } }); +}); - it("uses red for yellow-like segment colors", () => { - const sourceColor = vec3.fromValues(1, 0.95, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); +describe("node highlight palettes", () => { + it("give distinct hovered and selected colors for every segment color", () => { + for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { + const sourceColor = vec3.fromValues(...channels); + const hovered = computeHoveredNodeHighlightColor( + vec3.create(), + sourceColor, + ); + const selected = computeSelectedNodeHighlightColor( + vec3.create(), + sourceColor, + ); + expect([...hovered]).not.toEqual([...selected]); + } }); - it("uses red when yellow would be close to the segment color", () => { - const sourceColor = vec3.fromValues(0.35, 1, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); + it("never blend into their own segment", () => { + for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { + const sourceColor = vec3.fromValues(...channels); + const hovered = computeHoveredNodeHighlightColor( + vec3.create(), + sourceColor, + ); + const selected = computeSelectedNodeHighlightColor( + vec3.create(), + sourceColor, + ); + // Both clear the value 1.0 that a same-color-on-same-color outline gives. + expect(getContrastRatio(hovered, sourceColor)).toBeGreaterThan(1.5); + expect(getContrastRatio(selected, sourceColor)).toBeGreaterThan(1.5); + } }); }); diff --git a/src/util/color.ts b/src/util/color.ts index 45d8058b81..256ccee416 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -182,23 +182,78 @@ export function useWhiteBackground(foregroundColor: vec3 | vec4) { return getRelativeLuminance(foregroundColor) <= 0.179; } -const yellowHighlight = vec3.fromValues(1, 0.95, 0.35); -const redHighlight = vec3.fromValues(1, 0, 0); -const YELLOW_HIGHLIGHT_CONTRAST_BIAS = 1.2; +// Two disjoint palettes drive the node outline highlights. For a given segment +// fill color, an outline is the palette entry with the highest contrast against +// that segment. The hovered and selected colors are each computed independently +// from their own palette, so each is fully determined by the segment color alone +// (the same segment always yields the same hovered color and the same selected +// color), and the two never collide since the palettes are disjoint. -export function computeHighVisibilityContrastColor( +// Vivid, saturated colors for the hovered node -- the actively pointed-at node, +// drawn to stand out. Spans hue and luminance (white through blue) so a +// high-contrast option exists for any segment color. +const HOVERED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + vec3.fromValues(1.0, 1.0, 1.0), // white + vec3.fromValues(1.0, 0.95, 0.0), // yellow + // vec3.fromValues(0.0, 0.95, 1.0), // cyan + // vec3.fromValues(0.1, 1.0, 0.25), // green + // vec3.fromValues(1.0, 0.55, 0.0), // orange + // vec3.fromValues(1.0, 0.1, 0.65), // pink + vec3.fromValues(1.0, 0.12, 0.12), // red + // vec3.fromValues(0.2, 0.45, 1.0), // blue +]; + +// Muted, lower-chroma colors for the selected (pinned) node -- a calmer, +// persistent highlight. Spans hue and luminance like the hovered set so a +// reasonable-contrast option exists for any segment color. +const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + vec3.fromValues(0.1, 0.1, 0.1), // near-black + vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) + vec3.fromValues(0.5, 0.45, 0.15), // olive + // vec3.fromValues(0.15, 0.42, 0.42), // teal + // vec3.fromValues(0.5, 0.18, 0.18), // maroon + // vec3.fromValues(0.25, 0.3, 0.5), // slate blue + // vec3.fromValues(0.42, 0.22, 0.45), // plum + // vec3.fromValues(0.25, 0.42, 0.22), // moss + // vec3.fromValues(0.5, 0.38, 0.2), // tan +]; + +// Returns the palette color with the highest contrast against `sourceColor`. +function pickHighestContrastColor( + palette: readonly vec3[], + sourceColor: ArrayLike, +): vec3 { + let bestColor = palette[0]; + let bestContrast = -1; + for (const candidate of palette) { + const contrast = getContrastRatio(candidate, sourceColor); + if (contrast > bestContrast) { + bestContrast = contrast; + bestColor = candidate; + } + } + return bestColor; +} + +// Writes into `out` the vivid hovered-node outline color with the highest +// contrast against `sourceColor`. +export function computeHoveredNodeHighlightColor( out: T, sourceColor: ArrayLike, -) { - const yellowContrast = getContrastRatio(yellowHighlight, sourceColor); - const redContrast = getContrastRatio(redHighlight, sourceColor); - const color = - redContrast > yellowContrast * YELLOW_HIGHLIGHT_CONTRAST_BIAS - ? redHighlight - : yellowHighlight; - out[0] = color[0]; - out[1] = color[1]; - out[2] = color[2]; +): T { + out.set(pickHighestContrastColor(HOVERED_NODE_HIGHLIGHT_COLORS, sourceColor)); + return out; +} + +// Writes into `out` the muted selected-node outline color with the highest +// contrast against `sourceColor`. +export function computeSelectedNodeHighlightColor( + out: T, + sourceColor: ArrayLike, +): T { + out.set( + pickHighestContrastColor(SELECTED_NODE_HIGHLIGHT_COLORS, sourceColor), + ); return out; } From f3f21844468c8310effe6b78246a026961c09185 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 12:14:56 +0200 Subject: [PATCH 04/13] fix: add back unconditional node draw --- src/skeleton/frontend.ts | 271 +++++++++++++++++---------------------- 1 file changed, 115 insertions(+), 156 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 11ccea656f..0150f2c4c6 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -975,7 +975,6 @@ void emitDefault() { nodeShader: ShaderProgram, skeletonGpuGeometry: SkeletonGPUGeometry, projectionParameters: { width: number; height: number }, - drawNodes: boolean, ) { // Bind vertex attribute textures to be used across edge and node shaders // The edge shader and node shader share the same texture unit for each attribute @@ -1015,8 +1014,8 @@ void emitDefault() { gl.disableVertexAttribArray(aVertexIndex); } - // Draw nodes if in line and node mode - if (drawNodes) { + // Draw nodes + { nodeShader.bind(); initializeCircleShader(nodeShader, projectionParameters, { featherWidthInPixels: this.targetIsSliceView ? 1.0 : 0.0, @@ -1348,9 +1347,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { const { shaderControlState } = this.displayState.skeletonRenderingOptions; - const drawNodes = - renderOptions.mode.value === SkeletonRenderMode.LINES_AND_POINTS; - edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); renderHelper.setPickInstanceStride(gl, edgeShader, 0); @@ -1363,24 +1359,21 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth!); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); const skeletons = source.chunks; - forEachVisibleSegmentToDraw( displayState, layer, @@ -1395,21 +1388,19 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { ) { return; } + edgeShader.bind(); if (color !== undefined) { - edgeShader.bind(); renderHelper.setColor(gl, edgeShader, color); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, color); - } } if (pickIndex !== undefined) { - edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, pickIndex); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, pickIndex); - } + } + nodeShader.bind(); + if (color !== undefined) { + renderHelper.setColor(gl, nodeShader, color); + } + if (pickIndex !== undefined) { + renderHelper.setPickID(gl, nodeShader, pickIndex); } renderHelper.drawSkeletons( gl, @@ -1417,7 +1408,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { nodeShader, skeleton, renderContext.projectionParameters, - drawNodes, ); }, ); @@ -2881,7 +2871,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, excludedGPUTable?: GPUHashTable, ): | { @@ -2907,7 +2896,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShaderResult; if (edgeShader === null || nodeShader === null) return undefined; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; const { shaderControlState } = this.displayState.skeletonRenderingOptions; edgeShader.bind(); @@ -2915,7 +2903,7 @@ export class SpatiallyIndexedSkeletonLayer gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); renderHelper.setPickInstanceStride(gl, edgeShader, 0); setControlsInShader( @@ -2932,25 +2920,23 @@ export class SpatiallyIndexedSkeletonLayer excludedGPUTable, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - renderHelper.setColor(gl, nodeShader, kOneVec4); - renderHelper.maybeEnableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - excludedGPUTable, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); + renderHelper.setColor(gl, nodeShader, kOneVec4); + renderHelper.maybeEnableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + excludedGPUTable, + ); return { gl, @@ -2966,20 +2952,17 @@ export class SpatiallyIndexedSkeletonLayer edgeShader: ShaderProgram, nodeShader: ShaderProgram, skeletonParams: SkeletonShaderParameters, - drawNodes: boolean, ) { renderHelper.maybeDisableDynamicSegmentAppearance( gl, edgeShader, skeletonParams, ); - if (drawNodes) { - renderHelper.maybeDisableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - ); - } + renderHelper.maybeDisableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + ); renderHelper.endLayer(gl, edgeShader, nodeShader); } @@ -2990,7 +2973,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, visibleChunks: VisibleChunk[], ) { if (visibleChunks.length === 0) return; @@ -3002,33 +2984,29 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, hasExcludedSegments ? this.gpuBrowseExcludedSegmentsHashTable : undefined, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - this.updateNodeOutlineColorPair(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.selectedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - gl.uniform3fv( - nodeShader.uniform("uHighlightedNodeOutlineColor"), - this.highlightedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uHighlightedNodeId"), - this.hoveredNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColorPair(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); const chunkOrigin = vec3.create(); const chunkBound = vec3.create(); @@ -3038,10 +3016,8 @@ export class SpatiallyIndexedSkeletonLayer vec3.add(chunkBound, chunkOrigin, chunkLayout.size); edgeShader.bind(); renderHelper.setChunkBounds(gl, edgeShader, chunkOrigin, chunkBound); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); - } + nodeShader.bind(); + renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); } if (renderContext.emitPickID) { let edgePickId = 0; @@ -3060,7 +3036,7 @@ export class SpatiallyIndexedSkeletonLayer ); edgePickStride = 1; } - if (chunk.numVertices > 0 && drawNodes) { + if (chunk.numVertices > 0) { nodePickId = renderContext.pickIDs.register( layer, chunk.numVertices, @@ -3075,11 +3051,9 @@ export class SpatiallyIndexedSkeletonLayer edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, edgePickId); renderHelper.setPickInstanceStride(gl, edgeShader, edgePickStride); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); - } + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); } // Render each chunk with different node/edge colors for debugging @@ -3101,13 +3075,11 @@ export class SpatiallyIndexedSkeletonLayer tempChunkKeyToColorMap.set(chunkKey, randomColor); } if (skeletonParams.hasSegmentDefaultColor) { - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSegmentDefaultColor"), - randomColor, - ); - } + nodeShader.bind(); + gl.uniform3fv( + nodeShader.uniform("uSegmentDefaultColor"), + randomColor, + ); edgeShader.bind(); gl.uniform3fv( edgeShader.uniform("uSegmentDefaultColor"), @@ -3122,7 +3094,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, chunk, renderContext.projectionParameters, - drawNodes, ); } this.endSkeletonRenderPass( @@ -3131,7 +3102,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3142,7 +3112,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, ) { const overlayChunk = this.resolveSourceBackedOverlayChunk(); if (overlayChunk === undefined) return; @@ -3152,32 +3121,28 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - this.updateNodeOutlineColorPair(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.selectedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - gl.uniform3fv( - nodeShader.uniform("uHighlightedNodeOutlineColor"), - this.highlightedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uHighlightedNodeId"), - this.hoveredNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColorPair(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); if (renderContext.emitPickID) { const edgePickId = @@ -3202,32 +3167,30 @@ export class SpatiallyIndexedSkeletonLayer edgePickId === 0 ? 0 : 1, ); - if (drawNodes) { - const nodePickId = - overlayChunk.numVertices > 0 && - overlayChunk.pickNodeIds !== undefined && - overlayChunk.pickNodePositions !== undefined && - overlayChunk.pickSegmentIds !== undefined - ? renderContext.pickIDs.register( - layer, - overlayChunk.numVertices, - 0n, - { - kind: "node", - nodeIds: overlayChunk.pickNodeIds, - nodePositions: overlayChunk.pickNodePositions, - segmentIds: overlayChunk.pickSegmentIds, - } satisfies SpatiallyIndexedSkeletonPickData, - ) - : 0; - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride( - gl, - nodeShader, - nodePickId === 0 ? 0 : 1, - ); - } + const nodePickId = + overlayChunk.numVertices > 0 && + overlayChunk.pickNodeIds !== undefined && + overlayChunk.pickNodePositions !== undefined && + overlayChunk.pickSegmentIds !== undefined + ? renderContext.pickIDs.register( + layer, + overlayChunk.numVertices, + 0n, + { + kind: "node", + nodeIds: overlayChunk.pickNodeIds, + nodePositions: overlayChunk.pickNodePositions, + segmentIds: overlayChunk.pickSegmentIds, + } satisfies SpatiallyIndexedSkeletonPickData, + ) + : 0; + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride( + gl, + nodeShader, + nodePickId === 0 ? 0 : 1, + ); } renderHelper.drawSkeletons( @@ -3236,7 +3199,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, overlayChunk, renderContext.projectionParameters, - drawNodes, ); this.endSkeletonRenderPass( renderHelper, @@ -3244,7 +3206,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3278,7 +3239,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, visibleChunks, ); this.drawInspectionOverlayPass( @@ -3288,7 +3248,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, ); } From 8c6e5ca36415131f8578575b1554640aa5f94cec Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Jun 2026 14:58:47 +0200 Subject: [PATCH 05/13] fix: block undo/redo having many at the same time --- src/skeleton/spatial_skeleton_commands.ts | 36 +++++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/skeleton/spatial_skeleton_commands.ts b/src/skeleton/spatial_skeleton_commands.ts index 83731a5f09..6e9e93d4d3 100644 --- a/src/skeleton/spatial_skeleton_commands.ts +++ b/src/skeleton/spatial_skeleton_commands.ts @@ -346,19 +346,43 @@ export function executeSpatialSkeletonMerge( export async function undoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const changed = await layer.spatialSkeletonState.commandHistory.undo(); - if (!changed) { + const { commandHistory } = layer.spatialSkeletonState; + if (commandHistory.isBusy.value) { + StatusMessage.showTemporaryMessage( + "Wait for the current skeleton edit to finish.", + ); + return false; + } + if (!commandHistory.canUndo.value) { return false; } - return true; + const undoLabel = commandHistory.undoLabel.value; + const pendingMessage = + undoLabel !== undefined ? `Undoing ${undoLabel}...` : "Undoing..."; + return executeCommandWithPendingMessage( + commandHistory.undo(), + pendingMessage, + ); } export async function redoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const changed = await layer.spatialSkeletonState.commandHistory.redo(); - if (!changed) { + const { commandHistory } = layer.spatialSkeletonState; + if (commandHistory.isBusy.value) { + StatusMessage.showTemporaryMessage( + "Wait for the current skeleton edit to finish.", + ); + return false; + } + if (!commandHistory.canRedo.value) { return false; } - return true; + const redoLabel = commandHistory.redoLabel.value; + const pendingMessage = + redoLabel !== undefined ? `Redoing ${redoLabel}...` : "Redoing..."; + return executeCommandWithPendingMessage( + commandHistory.redo(), + pendingMessage, + ); } From 434b8d2344058c6477eb6582753b36e4cecae21b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Jun 2026 20:19:18 +0200 Subject: [PATCH 06/13] feat: add second outline to node border --- src/webgl/circles.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/webgl/circles.ts b/src/webgl/circles.ts index 3015542127..b98ae743e4 100644 --- a/src/webgl/circles.ts +++ b/src/webgl/circles.ts @@ -39,22 +39,29 @@ export function defineCircleShader( // 2-D position within circle quad, ranging from [-1, -1] to [1, 1]. builder.addVarying("highp vec4", "vCircleCoord"); + // Normalized radius where the first border ends and the border outline begins. + builder.addVarying("highp float", "vCircleBorderFraction"); builder.addVertexCode(` -void emitCircle(vec4 position, float diameter, float borderWidth) { +void emitCircle(vec4 position, float diameter, float borderWidth, float borderOutlineWidth) { gl_Position = position; - float totalDiameter = diameter + 2.0 * (borderWidth + uCircleParams.z); + float totalDiameter = diameter + 2.0 * (borderWidth + borderOutlineWidth + uCircleParams.z); if (diameter == 0.0) totalDiameter = 0.0; vec2 circleCornerOffset = getQuadVertexPosition(vec2(-1.0, -1.0), vec2(1.0, 1.0)); gl_Position.xy += circleCornerOffset * uCircleParams.xy * gl_Position.w * totalDiameter; vCircleCoord.xy = circleCornerOffset; - if (borderWidth == 0.0) { + if (borderWidth == 0.0 && borderOutlineWidth == 0.0) { vCircleCoord.z = totalDiameter; vCircleCoord.w = 1e-6; + vCircleBorderFraction = totalDiameter; } else { vCircleCoord.z = diameter / totalDiameter; + vCircleBorderFraction = (diameter + 2.0 * borderWidth) / totalDiameter; vCircleCoord.w = uCircleParams.z / totalDiameter; } } +void emitCircle(vec4 position, float diameter, float borderWidth) { + emitCircle(position, diameter, borderWidth, 0.0); +} `); if (crossSectionFade) { builder.addFragmentCode(` @@ -70,18 +77,23 @@ float getCircleAlphaMultiplier() { `); } builder.addFragmentCode(` -vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor, vec4 borderOutlineColor) { float radius = length(vCircleCoord.xy); if (radius > 1.0) { discard; } float borderColorFraction = clamp((radius - vCircleCoord.z) / vCircleCoord.w, 0.0, 1.0); + float outlineColorFraction = clamp((radius - vCircleBorderFraction) / vCircleCoord.w, 0.0, 1.0); float feather = clamp((1.0 - radius) / vCircleCoord.w, 0.0, 1.0); vec4 color = mix(interiorColor, borderColor, borderColorFraction); + color = mix(color, borderOutlineColor, outlineColorFraction); return vec4(color.rgb, color.a * feather * getCircleAlphaMultiplier()); } +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { + return getCircleColor(interiorColor, borderColor, borderColor); +} `); } From a678b86d7ddae6e637cfcf4221a1ae8eb6409482 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Jun 2026 20:23:52 +0200 Subject: [PATCH 07/13] feat: link outline to skeleton fe --- src/skeleton/frontend.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 0150f2c4c6..a04ec15de1 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -208,6 +208,12 @@ const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; // the min for typical nodes and scales up the ring for larger nodes. const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; +const NODE_BORDER_OUTLINE_DIAMETER_FRACTION = "0.15"; +const NODE_BORDER_OUTLINE_MIN_WIDTH_2D = "1.0"; +const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; +const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; +const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -767,6 +773,7 @@ void emitDefault() { ); builder.addUniform("highp float", "uNodeDiameter"); let selectedOutlineWidthExpression = "0.0"; + let borderOutlineWidthExpression = "0.0"; if (this.nodeIdAttributeIndex !== undefined) { builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); builder.addUniform("highp int", "uSelectedNodeId"); @@ -781,6 +788,13 @@ void emitDefault() { ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${SELECTED_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; + const borderOutlineMinWidth = this.targetIsSliceView + ? NODE_BORDER_OUTLINE_MIN_WIDTH_2D + : NODE_BORDER_OUTLINE_MIN_WIDTH_3D; + const borderOutlineMaxWidth = this.targetIsSliceView + ? NODE_BORDER_OUTLINE_MAX_WIDTH_2D + : NODE_BORDER_OUTLINE_MAX_WIDTH_3D; + borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${NODE_BORDER_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -805,7 +819,8 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); emitCircle( uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, - ${selectedOutlineWidthExpression} + ${selectedOutlineWidthExpression}, + ${borderOutlineWidthExpression} ); `; const segmentColorExpression = this.getSegmentColorExpression(); @@ -823,6 +838,9 @@ emitCircle( const borderColorExpression = hasNodeIdSelection ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(1.0, 1.0, 1.0, renderColor.a), vSelectedNode), vec4(0.0, 0.0, 0.0, renderColor.a), vHighlightedNode)` + : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return getSegmentAppearance(${segmentExpression}); @@ -833,7 +851,8 @@ void emitRGBA(vec4 color) { if (alpha <= 0.0) discard; vec4 renderColor = vec4(color.rgb, alpha); vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -871,6 +890,9 @@ void emitDefault() { const borderColorExpression = hasNodeIdSelection ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(1.0, 1.0, 1.0, renderColor.a), vSelectedNode), vec4(0.0, 0.0, 0.0, renderColor.a), vHighlightedNode)` + : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return ${segmentColorExpression}; @@ -878,7 +900,8 @@ vec4 segmentColor() { void emitRGBA(vec4 color) { vec4 renderColor = color; vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { From 41c08ae34c2d78aa760cfbe8d57c53949ea69354 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 30 Jun 2026 11:02:08 +0200 Subject: [PATCH 08/13] refactor: update color handling --- src/skeleton/frontend.ts | 49 +++++++++++++++------- src/util/color.ts | 88 +++++++--------------------------------- src/webgl/lines.ts | 3 +- 3 files changed, 51 insertions(+), 89 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index a04ec15de1..0c4a8ac8b0 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,10 +117,7 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { - computeHoveredNodeHighlightColor, - computeSelectedNodeHighlightColor, -} from "#src/util/color.js"; +import { pickHighestContrastColor } from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -189,15 +186,11 @@ import type { RPC } from "#src/worker_rpc.js"; const DEBUG_SPATIAL_SKELETON_OVERLAY = false; const DEBUG_EXCLUDED_SEGMENTS = false; const DEBUG_SPATIAL_SKELETON_CHUNKS = false; -// Used for debugging chunks via a different color for each chunk -const tempChunkKeyToColorMap = new Map(); -const tempMat4 = mat4.create(); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; - const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "3.5"; const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "8.0"; @@ -207,13 +200,35 @@ const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; // clamping to the min/max above. Nodes are small (~5-6px), so this mostly hits // the min for typical nodes and scales up the ring for larger nodes. const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; - const NODE_BORDER_OUTLINE_DIAMETER_FRACTION = "0.15"; const NODE_BORDER_OUTLINE_MIN_WIDTH_2D = "1.0"; const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; +// Vivid colors for the hovered node -- the actively pointed at node, +// drawn to stand out. +const HOVERED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + // vec3.fromValues(1.0, 1.0, 1.0), // white + vec3.fromValues(1.0, 0.95, 0.0), // yellow + vec3.fromValues(0.0, 0.95, 1.0), // cyan + // vec3.fromValues(0.1, 1.0, 0.25), // green + // vec3.fromValues(1.0, 0.55, 0.0), // orange + // vec3.fromValues(1.0, 0.1, 0.65), // pink + // vec3.fromValues(0.2, 0.45, 1.0), // blue +]; + +// Muted colors for the selected (pinned) node -- less vibrant. +const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + vec3.fromValues(0.1, 0.1, 0.1), // near-black + vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) + vec3.fromValues(0.5, 0.45, 0.15), // olive +]; + +// Used for debugging chunks via a different color for each chunk +const tempChunkKeyToColorMap = new Map(); +const tempMat4 = mat4.create(); + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -2224,9 +2239,11 @@ export class SpatiallyIndexedSkeletonLayer ? this.getNodeSegmentColor(selectedNodeInfo) : undefined; if (selectedSegmentColor !== undefined) { - computeSelectedNodeHighlightColor( - this.selectedNodeOutlineColor, - selectedSegmentColor, + this.selectedNodeOutlineColor.set( + pickHighestContrastColor( + SELECTED_NODE_HIGHLIGHT_COLORS, + selectedSegmentColor, + ), ); } else { vec3.copy( @@ -2241,9 +2258,11 @@ export class SpatiallyIndexedSkeletonLayer ? this.getNodeSegmentColor(hoveredNodeInfo) : undefined; if (hoveredSegmentColor !== undefined) { - computeHoveredNodeHighlightColor( - this.highlightedNodeOutlineColor, - hoveredSegmentColor, + this.highlightedNodeOutlineColor.set( + pickHighestContrastColor( + HOVERED_NODE_HIGHLIGHT_COLORS, + hoveredSegmentColor, + ), ); } else { vec3.copy( diff --git a/src/util/color.ts b/src/util/color.ts index 256ccee416..3bfb32945a 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -142,9 +142,15 @@ export function serializeColor(x: vec3 | vec4) { return result; } -// Converts an sRGB color component to the gamma-expanded ("linear") value. -export function srgbGammaExpand(value: number) { - return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +// Determines whether a white background would provide higher contrast than a black background for +// the given foreground color. +// +// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: +// https://www.w3.org/TR/WCAG20/#contrast-ratiodef +// +// https://stackoverflow.com/a/3943023 +export function useWhiteBackground(foregroundColor: vec3 | vec4) { + return getRelativeLuminance(foregroundColor) <= 0.179; } // Computes the relative luminance according to Web Content Accessibility Guidelines (WCAG) 2.0 @@ -153,6 +159,11 @@ export function srgbGammaExpand(value: number) { // // @param color sRGB color export function getRelativeLuminance(color: ArrayLike) { + // Converts an sRGB color component to the gamma-expanded ("linear") value. + function srgbGammaExpand(value: number) { + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + } + return ( 0.2126 * srgbGammaExpand(color[0]) + 0.7152 * srgbGammaExpand(color[1]) + @@ -171,55 +182,8 @@ export function getContrastRatio( return (lighter + 0.05) / (darker + 0.05); } -// Determines whether a white background would provide higher contrast than a black background for -// the given foreground color. -// -// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: -// https://www.w3.org/TR/WCAG20/#contrast-ratiodef -// -// https://stackoverflow.com/a/3943023 -export function useWhiteBackground(foregroundColor: vec3 | vec4) { - return getRelativeLuminance(foregroundColor) <= 0.179; -} - -// Two disjoint palettes drive the node outline highlights. For a given segment -// fill color, an outline is the palette entry with the highest contrast against -// that segment. The hovered and selected colors are each computed independently -// from their own palette, so each is fully determined by the segment color alone -// (the same segment always yields the same hovered color and the same selected -// color), and the two never collide since the palettes are disjoint. - -// Vivid, saturated colors for the hovered node -- the actively pointed-at node, -// drawn to stand out. Spans hue and luminance (white through blue) so a -// high-contrast option exists for any segment color. -const HOVERED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ - vec3.fromValues(1.0, 1.0, 1.0), // white - vec3.fromValues(1.0, 0.95, 0.0), // yellow - // vec3.fromValues(0.0, 0.95, 1.0), // cyan - // vec3.fromValues(0.1, 1.0, 0.25), // green - // vec3.fromValues(1.0, 0.55, 0.0), // orange - // vec3.fromValues(1.0, 0.1, 0.65), // pink - vec3.fromValues(1.0, 0.12, 0.12), // red - // vec3.fromValues(0.2, 0.45, 1.0), // blue -]; - -// Muted, lower-chroma colors for the selected (pinned) node -- a calmer, -// persistent highlight. Spans hue and luminance like the hovered set so a -// reasonable-contrast option exists for any segment color. -const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ - vec3.fromValues(0.1, 0.1, 0.1), // near-black - vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) - vec3.fromValues(0.5, 0.45, 0.15), // olive - // vec3.fromValues(0.15, 0.42, 0.42), // teal - // vec3.fromValues(0.5, 0.18, 0.18), // maroon - // vec3.fromValues(0.25, 0.3, 0.5), // slate blue - // vec3.fromValues(0.42, 0.22, 0.45), // plum - // vec3.fromValues(0.25, 0.42, 0.22), // moss - // vec3.fromValues(0.5, 0.38, 0.2), // tan -]; - // Returns the palette color with the highest contrast against `sourceColor`. -function pickHighestContrastColor( +export function pickHighestContrastColor( palette: readonly vec3[], sourceColor: ArrayLike, ): vec3 { @@ -235,28 +199,6 @@ function pickHighestContrastColor( return bestColor; } -// Writes into `out` the vivid hovered-node outline color with the highest -// contrast against `sourceColor`. -export function computeHoveredNodeHighlightColor( - out: T, - sourceColor: ArrayLike, -): T { - out.set(pickHighestContrastColor(HOVERED_NODE_HIGHLIGHT_COLORS, sourceColor)); - return out; -} - -// Writes into `out` the muted selected-node outline color with the highest -// contrast against `sourceColor`. -export function computeSelectedNodeHighlightColor( - out: T, - sourceColor: ArrayLike, -): T { - out.set( - pickHighestContrastColor(SELECTED_NODE_HIGHLIGHT_COLORS, sourceColor), - ); - return out; -} - export class TrackableRGB extends WatchableValue { constructor(public defaultValue: vec3) { super(vec3.clone(defaultValue)); diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 55a8740fcd..5a345c1ec0 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -134,9 +134,10 @@ vec4 getRoundedLineColor(vec4 interiorColor, vec4 borderColor) { builder.addFragmentCode(` float getLineAlpha() { if (uLineEndpointClipRadius > 0.0) { + float radiusFactor = 0.99; float distFromA = length(vec2(vLineOffsetX * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); float distFromB = length(vec2((1.0 - vLineOffsetX) * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); - if (distFromA < uLineEndpointClipRadius || distFromB < uLineEndpointClipRadius) discard; + if (distFromA < uLineEndpointClipRadius * radiusFactor || distFromB < uLineEndpointClipRadius * radiusFactor) discard; } return clamp((1.0 - abs(vLineCoord)) / vLineFeatherFraction, 0.0, 1.0); } From e9a8fff8132d453825fee638974441bf619c662a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 30 Jun 2026 12:05:05 +0200 Subject: [PATCH 09/13] feat: pick color based on current color for highlight --- src/skeleton/frontend.ts | 26 ++--- src/util/color.browser_test.ts | 205 --------------------------------- src/util/color.ts | 11 ++ 3 files changed, 21 insertions(+), 221 deletions(-) delete mode 100644 src/util/color.browser_test.ts diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 0c4a8ac8b0..0d10db32b4 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,7 +117,7 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { pickHighestContrastColor } from "#src/util/color.js"; +import { pickHighestContrastColor, saturateColor } from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -206,17 +206,11 @@ const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; -// Vivid colors for the hovered node -- the actively pointed at node, -// drawn to stand out. -const HOVERED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ - // vec3.fromValues(1.0, 1.0, 1.0), // white - vec3.fromValues(1.0, 0.95, 0.0), // yellow - vec3.fromValues(0.0, 0.95, 1.0), // cyan - // vec3.fromValues(0.1, 1.0, 0.25), // green - // vec3.fromValues(1.0, 0.55, 0.0), // orange - // vec3.fromValues(1.0, 0.1, 0.65), // pink - // vec3.fromValues(0.2, 0.45, 1.0), // blue -]; +// Saturation boost factor for the highlighted node border: moves each channel +// away from the perceptual-grey axis by this multiplier (clamped to [0, 1]). +const HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR = 1.5; +const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; +const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; // Muted colors for the selected (pinned) node -- less vibrant. const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ @@ -854,7 +848,7 @@ emitCircle( ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; const borderOutlineColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(1.0, 1.0, 1.0, renderColor.a), vSelectedNode), vec4(0.0, 0.0, 0.0, renderColor.a), vHighlightedNode)` + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -906,7 +900,7 @@ void emitDefault() { ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` : "renderColor"; const borderOutlineColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(1.0, 1.0, 1.0, renderColor.a), vSelectedNode), vec4(0.0, 0.0, 0.0, renderColor.a), vHighlightedNode)` + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -2259,9 +2253,9 @@ export class SpatiallyIndexedSkeletonLayer : undefined; if (hoveredSegmentColor !== undefined) { this.highlightedNodeOutlineColor.set( - pickHighestContrastColor( - HOVERED_NODE_HIGHLIGHT_COLORS, + saturateColor( hoveredSegmentColor, + HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR, ), ); } else { diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts deleted file mode 100644 index a0e937c0da..0000000000 --- a/src/util/color.browser_test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright 2018 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from "vitest"; -import { - computeHoveredNodeHighlightColor, - computeSelectedNodeHighlightColor, - getContrastRatio, - parseColorSerialization, - parseRGBColorSpecification, - packColor, - serializeColor, - useWhiteBackground, -} from "#src/util/color.js"; -import { vec3, vec4 } from "#src/util/geom.js"; - -describe("color", () => { - it("parseColorSerialization works", () => { - expect(parseColorSerialization("#000000")).toEqual([0, 0, 0, 1]); - expect(parseColorSerialization("#123456")).toEqual([0x12, 0x34, 0x56, 1]); - expect(parseColorSerialization("rgba(101, 102, 103, 0.45)")).toEqual([ - 101, 102, 103, 0.45, - ]); - }); - - it("serializeColor works", () => { - expect( - serializeColor(vec3.fromValues(0x12 / 255, 0x34 / 255, 0x56 / 255)), - ).toEqual("#123456"); - expect( - serializeColor(vec4.fromValues(101 / 255, 102 / 255, 103 / 255, 0.45)), - ).toEqual("rgba(101, 102, 103, 0.45)"); - }); - - it("parseRGBColorSpecification works", () => { - expect(parseRGBColorSpecification("white")).toEqual( - vec3.fromValues(1, 1, 1), - ); - expect(parseRGBColorSpecification("black")).toEqual( - vec3.fromValues(0, 0, 0), - ); - expect(parseRGBColorSpecification("red")).toEqual(vec3.fromValues(1, 0, 0)); - expect(parseRGBColorSpecification("lime")).toEqual( - vec3.fromValues(0, 1, 0), - ); - expect(parseRGBColorSpecification("blue")).toEqual( - vec3.fromValues(0, 0, 1), - ); - }); - - it("packColor works", () => { - expect(packColor(vec3.fromValues(0, 0, 0))).toEqual(0x000000); - expect(packColor(vec3.fromValues(0.2, 0, 1))).toEqual(0xff0033); - expect(packColor(vec3.fromValues(0, 0.4, 1.0))).toEqual(0xff6600); - expect(packColor(vec3.fromValues(0.6, 0.4, 0))).toEqual(0x006699); - expect(packColor(vec3.fromValues(1, 0.6, 0.8))).toEqual(0xcc99ff); - expect(packColor(vec3.fromValues(1, 1, 1))).toEqual(0xffffff); - - expect(packColor(vec3.fromValues(-1, 0, 0))).toEqual(0x000000); - expect(packColor(vec3.fromValues(0, 0.2, 2))).toEqual(0xff3300); - expect(packColor(vec3.fromValues(0.4, 4.4, -0.4))).toEqual(0x00ff66); - - expect(packColor(vec4.fromValues(0, 0, 0, 0))).toEqual(0x00000000); - expect(packColor(vec4.fromValues(0.2, 0, 1, 0.2))).toEqual(0x33ff0033); - expect(packColor(vec4.fromValues(0, 0.4, 1.0, 0.4))).toEqual(0x66ff6600); - expect(packColor(vec4.fromValues(0.6, 0.4, 0, 0.6))).toEqual(0x99006699); - expect(packColor(vec4.fromValues(1, 0.6, 0.8, 0.8))).toEqual(0xcccc99ff); - expect(packColor(vec4.fromValues(1, 1, 1, 1))).toEqual(0xffffffff); - - expect(packColor(vec4.fromValues(-1, 0, 0, -1))).toEqual(0x00000000); - expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); - expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); - }); -}); - -function expectColorClose(actual: Float32Array, expected: readonly number[]) { - for (let i = 0; i < 3; ++i) { - expect(actual[i]).toBeCloseTo(expected[i]); - } -} - -describe("useWhiteBackground", () => { - it("works for simple cases", () => { - expect(useWhiteBackground(vec3.fromValues(0, 0, 0))).toBe(true); - expect(useWhiteBackground(vec3.fromValues(1, 1, 1))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(1, 0, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 1, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 0, 1))).toBe(true); - }); -}); - -describe("getContrastRatio", () => { - it("matches WCAG contrast-ratio reference values", () => { - expect( - getContrastRatio(vec3.fromValues(0, 0, 0), vec3.fromValues(1, 1, 1)), - ).toBeCloseTo(21); - expect( - getContrastRatio( - vec3.fromValues(0.5, 0.5, 0.5), - vec3.fromValues(0.5, 0.5, 0.5), - ), - ).toBeCloseTo(1); - }); -}); - -const REPRESENTATIVE_SEGMENT_COLORS: [number, number, number][] = [ - [0, 0, 0], // black - [1, 1, 1], // white - [0.5, 0.5, 0.5], // gray - [1, 0, 0], // red - [0, 1, 0], // green - [0, 0, 1], // blue - [1, 1, 0], // yellow - [0, 1, 1], // cyan - [1, 0, 1], // magenta - [1, 0.55, 0], // orange -]; - -describe("computeHoveredNodeHighlightColor", () => { - it("picks white for dark segments", () => { - const sourceColor = vec3.fromValues(0, 0, 0); - const color = computeHoveredNodeHighlightColor(vec3.create(), sourceColor); - expectColorClose(color, [1, 1, 1]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(7); - }); - - it("is fully determined by the segment color alone", () => { - for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { - const sourceColor = vec3.fromValues(...channels); - const first = computeHoveredNodeHighlightColor( - vec3.create(), - sourceColor, - ); - const second = computeHoveredNodeHighlightColor( - vec3.create(), - sourceColor, - ); - expect([...first]).toEqual([...second]); - } - }); -}); - -describe("computeSelectedNodeHighlightColor", () => { - it("is fully determined by the segment color alone", () => { - for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { - const sourceColor = vec3.fromValues(...channels); - const first = computeSelectedNodeHighlightColor( - vec3.create(), - sourceColor, - ); - const second = computeSelectedNodeHighlightColor( - vec3.create(), - sourceColor, - ); - expect([...first]).toEqual([...second]); - } - }); -}); - -describe("node highlight palettes", () => { - it("give distinct hovered and selected colors for every segment color", () => { - for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { - const sourceColor = vec3.fromValues(...channels); - const hovered = computeHoveredNodeHighlightColor( - vec3.create(), - sourceColor, - ); - const selected = computeSelectedNodeHighlightColor( - vec3.create(), - sourceColor, - ); - expect([...hovered]).not.toEqual([...selected]); - } - }); - - it("never blend into their own segment", () => { - for (const channels of REPRESENTATIVE_SEGMENT_COLORS) { - const sourceColor = vec3.fromValues(...channels); - const hovered = computeHoveredNodeHighlightColor( - vec3.create(), - sourceColor, - ); - const selected = computeSelectedNodeHighlightColor( - vec3.create(), - sourceColor, - ); - // Both clear the value 1.0 that a same-color-on-same-color outline gives. - expect(getContrastRatio(hovered, sourceColor)).toBeGreaterThan(1.5); - expect(getContrastRatio(selected, sourceColor)).toBeGreaterThan(1.5); - } - }); -}); diff --git a/src/util/color.ts b/src/util/color.ts index 3bfb32945a..6408635283 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -182,6 +182,17 @@ export function getContrastRatio( return (lighter + 0.05) / (darker + 0.05); } +// Returns a copy of `color` with saturation boosted by `factor` (moves each channel +// away from the perceptual-grey axis by the given multiplier, clamped to [0, 1]). +export function saturateColor(color: ArrayLike, factor: number): vec3 { + const lum = getRelativeLuminance(color); + return vec3.fromValues( + Math.min(1.0, Math.max(0.0, lum + (color[0] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[1] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[2] - lum) * factor)), + ); +} + // Returns the palette color with the highest contrast against `sourceColor`. export function pickHighestContrastColor( palette: readonly vec3[], From 35e47892f1db3d111e5b4eee15f435c39352ed6e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 1 Jul 2026 12:59:48 +0200 Subject: [PATCH 10/13] feat: allow desaturate on highly saturated --- src/skeleton/frontend.ts | 34 ++++++++++++++++++++++++---------- src/util/color.ts | 9 +++++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 0d10db32b4..cb23ce5713 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,7 +117,11 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { pickHighestContrastColor, saturateColor } from "#src/util/color.js"; +import { + getSaturation, + pickHighestContrastColor, + saturateColor, +} from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -206,11 +210,18 @@ const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; -// Saturation boost factor for the highlighted node border: moves each channel -// away from the perceptual-grey axis by this multiplier (clamped to [0, 1]). -const HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR = 1.5; +// Saturation adjustment factors for the highlighted (hovered) node border: each +// moves the segment's color away from (>1) or towards (<1) the perceptual-grey +// axis by this multiplier, clamped to [0, 1]. A segment color that is already +// very saturated has little room left to move further from grey, so boosting it +// further is barely visible; in that case the color is desaturated instead, which +// remains a visible change in either direction. Mirrors the saturation-flip +// logic in getObjectColor (segmentation_display_state/frontend.ts). +const HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR = 1.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR = 0.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD = 0.5; const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; -const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; +const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "0.0, 0.0, 0.0"; // Muted colors for the selected (pinned) node -- less vibrant. const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ @@ -2217,7 +2228,8 @@ export class SpatiallyIndexedSkeletonLayer // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in // place. Each outline is chosen, independently of the other, for high contrast // against its own node's segment color: the selected node uses the muted - // palette and the hovered node the vivid palette. Because the two are computed + // palette, and the hovered node uses its own segment color pushed away from + // (or, if already very saturated, towards) grey. Because the two are computed // independently, a given segment color always yields the same selected color // and the same hovered color. private updateNodeOutlineColorPair() { @@ -2252,11 +2264,13 @@ export class SpatiallyIndexedSkeletonLayer ? this.getNodeSegmentColor(hoveredNodeInfo) : undefined; if (hoveredSegmentColor !== undefined) { + const saturationFactor = + getSaturation(hoveredSegmentColor) > + HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD + ? HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR + : HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR; this.highlightedNodeOutlineColor.set( - saturateColor( - hoveredSegmentColor, - HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR, - ), + saturateColor(hoveredSegmentColor, saturationFactor), ); } else { vec3.copy( diff --git a/src/util/color.ts b/src/util/color.ts index 6408635283..f89d2d392c 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -182,6 +182,15 @@ export function getContrastRatio( return (lighter + 0.05) / (darker + 0.05); } +// Returns the HSV saturation of `color`: the fraction by which its most intense +// channel exceeds its least intense channel. 0 for greys, 1 for fully saturated colors. +export function getSaturation(color: ArrayLike): number { + const max = Math.max(color[0], color[1], color[2]); + if (max <= 0) return 0; + const min = Math.min(color[0], color[1], color[2]); + return (max - min) / max; +} + // Returns a copy of `color` with saturation boosted by `factor` (moves each channel // away from the perceptual-grey axis by the given multiplier, clamped to [0, 1]). export function saturateColor(color: ArrayLike, factor: number): vec3 { From b636cedbc5090b72a10a2cb0ac06d374a4302222 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 6 Jul 2026 13:23:58 +0200 Subject: [PATCH 11/13] Revert "fix: block undo/redo having many at the same time" This reverts commit 8c6e5ca36415131f8578575b1554640aa5f94cec. --- src/skeleton/spatial_skeleton_commands.ts | 36 ++++------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/skeleton/spatial_skeleton_commands.ts b/src/skeleton/spatial_skeleton_commands.ts index 6e9e93d4d3..83731a5f09 100644 --- a/src/skeleton/spatial_skeleton_commands.ts +++ b/src/skeleton/spatial_skeleton_commands.ts @@ -346,43 +346,19 @@ export function executeSpatialSkeletonMerge( export async function undoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const { commandHistory } = layer.spatialSkeletonState; - if (commandHistory.isBusy.value) { - StatusMessage.showTemporaryMessage( - "Wait for the current skeleton edit to finish.", - ); - return false; - } - if (!commandHistory.canUndo.value) { + const changed = await layer.spatialSkeletonState.commandHistory.undo(); + if (!changed) { return false; } - const undoLabel = commandHistory.undoLabel.value; - const pendingMessage = - undoLabel !== undefined ? `Undoing ${undoLabel}...` : "Undoing..."; - return executeCommandWithPendingMessage( - commandHistory.undo(), - pendingMessage, - ); + return true; } export async function redoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const { commandHistory } = layer.spatialSkeletonState; - if (commandHistory.isBusy.value) { - StatusMessage.showTemporaryMessage( - "Wait for the current skeleton edit to finish.", - ); - return false; - } - if (!commandHistory.canRedo.value) { + const changed = await layer.spatialSkeletonState.commandHistory.redo(); + if (!changed) { return false; } - const redoLabel = commandHistory.redoLabel.value; - const pendingMessage = - redoLabel !== undefined ? `Redoing ${redoLabel}...` : "Redoing..."; - return executeCommandWithPendingMessage( - commandHistory.redo(), - pendingMessage, - ); + return true; } From 3b11f8d5cf2605b1ad5684b252157b50776576fd Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 6 Jul 2026 14:09:09 +0200 Subject: [PATCH 12/13] refactor: simplify num control consts, remove uneeded comments --- src/skeleton/frontend.ts | 80 ++++++++++++++-------------------------- src/webgl/lines.ts | 3 +- 2 files changed, 29 insertions(+), 54 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index cb23ce5713..6d606d95d7 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -195,35 +195,26 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; -const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "3.5"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "8.0"; -const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "3.0"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; -// Fraction of the node diameter used as the highlight outline width before -// clamping to the min/max above. Nodes are small (~5-6px), so this mostly hits -// the min for typical nodes and scales up the ring for larger nodes. -const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; -const NODE_BORDER_OUTLINE_DIAMETER_FRACTION = "0.15"; -const NODE_BORDER_OUTLINE_MIN_WIDTH_2D = "1.0"; -const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; -const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; -const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; - -// Saturation adjustment factors for the highlighted (hovered) node border: each +// If use values like 8.0, need to ensure JS keeps the decimal place for GLSL +const ACTIVE_NODE_BORDER_MIN_WIDTH = 3.5; +const ACTIVE_NODE_BORDER_MAX_WIDTH = 8.5; +const ACTIVE_NODE_BORDER_DIAMETER_FRACTION = 0.5; +const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; + +// Saturation adjustment factor and threshold for the highlighted (hovered) node border: each // moves the segment's color away from (>1) or towards (<1) the perceptual-grey // axis by this multiplier, clamped to [0, 1]. A segment color that is already // very saturated has little room left to move further from grey, so boosting it // further is barely visible; in that case the color is desaturated instead, which // remains a visible change in either direction. Mirrors the saturation-flip // logic in getObjectColor (segmentation_display_state/frontend.ts). -const HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR = 1.5; -const HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR = 0.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR = 0.5; const HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD = 0.5; + const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "0.0, 0.0, 0.0"; - -// Muted colors for the selected (pinned) node -- less vibrant. +const ACTIVE_NODE_BORDER_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); +// Muted colors for the selected (pinned) node const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ vec3.fromValues(0.1, 0.1, 0.1), // near-black vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) @@ -801,20 +792,14 @@ void emitDefault() { builder.addUniform("highp vec3", "uHighlightedNodeOutlineColor"); builder.addUniform("highp int", "uHighlightedNodeId"); builder.addVarying("highp float", "vHighlightedNode", "flat"); - const selectedOutlineMinWidth = this.targetIsSliceView - ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D - : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D; - const selectedOutlineMaxWidth = this.targetIsSliceView - ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D - : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; - selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${SELECTED_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; - const borderOutlineMinWidth = this.targetIsSliceView - ? NODE_BORDER_OUTLINE_MIN_WIDTH_2D - : NODE_BORDER_OUTLINE_MIN_WIDTH_3D; - const borderOutlineMaxWidth = this.targetIsSliceView - ? NODE_BORDER_OUTLINE_MAX_WIDTH_2D - : NODE_BORDER_OUTLINE_MAX_WIDTH_3D; - borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${NODE_BORDER_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; + selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_BORDER_DIAMETER_FRACTION} * uNodeDiameter, ${ACTIVE_NODE_BORDER_MIN_WIDTH}, ${ACTIVE_NODE_BORDER_MAX_WIDTH}))`; + const borderOutlineMinWidth = + ACTIVE_NODE_BORDER_MIN_WIDTH * + ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; + const borderOutlineMaxWidth = + ACTIVE_NODE_BORDER_MAX_WIDTH * + ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; + borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -2148,10 +2133,10 @@ export class SpatiallyIndexedSkeletonLayer private retainedOverlaySegmentIds: number[] = []; private maxRetainedOverlaySegments: number; private readonly selectedNodeOutlineColor = vec3.clone( - SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, ); private readonly highlightedNodeOutlineColor = vec3.clone( - SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, ); // The selected and hovered outline colors are derived together from a single // source segment color, so they share one cache generation. @@ -2225,14 +2210,7 @@ export class SpatiallyIndexedSkeletonLayer return getBaseObjectColor(this.displayState, segmentId); } - // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in - // place. Each outline is chosen, independently of the other, for high contrast - // against its own node's segment color: the selected node uses the muted - // palette, and the hovered node uses its own segment color pushed away from - // (or, if already very saturated, towards) grey. Because the two are computed - // independently, a given segment color always yields the same selected color - // and the same hovered color. - private updateNodeOutlineColorPair() { + private updateNodeOutlineColors() { const currentGeneration = this.nodeOutlineColorGeneration; if (this.cachedNodeOutlineColorGeneration === currentGeneration) { return; @@ -2254,7 +2232,7 @@ export class SpatiallyIndexedSkeletonLayer } else { vec3.copy( this.selectedNodeOutlineColor, - SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, ); } @@ -2267,15 +2245,15 @@ export class SpatiallyIndexedSkeletonLayer const saturationFactor = getSaturation(hoveredSegmentColor) > HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD - ? HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR - : HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR; + ? 1.0 - HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR + : 1.0 + HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR; this.highlightedNodeOutlineColor.set( saturateColor(hoveredSegmentColor, saturationFactor), ); } else { vec3.copy( this.highlightedNodeOutlineColor, - SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, ); } } @@ -2598,8 +2576,6 @@ export class SpatiallyIndexedSkeletonLayer if (this.hoveredNodeInfo?.changed) { this.registerDisposer( this.hoveredNodeInfo.changed.add(() => { - // The hovered node drives both which node is outlined and the source - // segment color of its outline. invalidateNodeOutlineColors(); requestRedraw(); }), @@ -3040,7 +3016,7 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColorPair(); + this.updateNodeOutlineColors(); gl.uniform3fv( nodeShader.uniform("uSelectedNodeOutlineColor"), this.selectedNodeOutlineColor, @@ -3176,7 +3152,7 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColorPair(); + this.updateNodeOutlineColors(); gl.uniform3fv( nodeShader.uniform("uSelectedNodeOutlineColor"), this.selectedNodeOutlineColor, diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 5a345c1ec0..55a8740fcd 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -134,10 +134,9 @@ vec4 getRoundedLineColor(vec4 interiorColor, vec4 borderColor) { builder.addFragmentCode(` float getLineAlpha() { if (uLineEndpointClipRadius > 0.0) { - float radiusFactor = 0.99; float distFromA = length(vec2(vLineOffsetX * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); float distFromB = length(vec2((1.0 - vLineOffsetX) * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); - if (distFromA < uLineEndpointClipRadius * radiusFactor || distFromB < uLineEndpointClipRadius * radiusFactor) discard; + if (distFromA < uLineEndpointClipRadius || distFromB < uLineEndpointClipRadius) discard; } return clamp((1.0 - abs(vLineCoord)) / vLineFeatherFraction, 0.0, 1.0); } From e656f7182801cc3f4babfcd25947fe836826f28d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 11 Aug 2026 20:55:58 +0200 Subject: [PATCH 13/13] fix(test): restore accidental delete --- src/util/color.browser_test.ts | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/util/color.browser_test.ts diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts new file mode 100644 index 0000000000..90b60f53fb --- /dev/null +++ b/src/util/color.browser_test.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2018 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + parseColorSerialization, + parseRGBColorSpecification, + packColor, + serializeColor, +} from "#src/util/color.js"; +import { vec3, vec4 } from "#src/util/geom.js"; + +describe("color", () => { + it("parseColorSerialization works", () => { + expect(parseColorSerialization("#000000")).toEqual([0, 0, 0, 1]); + expect(parseColorSerialization("#123456")).toEqual([0x12, 0x34, 0x56, 1]); + expect(parseColorSerialization("rgba(101, 102, 103, 0.45)")).toEqual([ + 101, 102, 103, 0.45, + ]); + }); + + it("serializeColor works", () => { + expect( + serializeColor(vec3.fromValues(0x12 / 255, 0x34 / 255, 0x56 / 255)), + ).toEqual("#123456"); + expect( + serializeColor(vec4.fromValues(101 / 255, 102 / 255, 103 / 255, 0.45)), + ).toEqual("rgba(101, 102, 103, 0.45)"); + }); + + it("parseRGBColorSpecification works", () => { + expect(parseRGBColorSpecification("white")).toEqual( + vec3.fromValues(1, 1, 1), + ); + expect(parseRGBColorSpecification("black")).toEqual( + vec3.fromValues(0, 0, 0), + ); + expect(parseRGBColorSpecification("red")).toEqual(vec3.fromValues(1, 0, 0)); + expect(parseRGBColorSpecification("lime")).toEqual( + vec3.fromValues(0, 1, 0), + ); + expect(parseRGBColorSpecification("blue")).toEqual( + vec3.fromValues(0, 0, 1), + ); + }); + + it("packColor works", () => { + expect(packColor(vec3.fromValues(0, 0, 0))).toEqual(0x000000); + expect(packColor(vec3.fromValues(0.2, 0, 1))).toEqual(0xff0033); + expect(packColor(vec3.fromValues(0, 0.4, 1.0))).toEqual(0xff6600); + expect(packColor(vec3.fromValues(0.6, 0.4, 0))).toEqual(0x006699); + expect(packColor(vec3.fromValues(1, 0.6, 0.8))).toEqual(0xcc99ff); + expect(packColor(vec3.fromValues(1, 1, 1))).toEqual(0xffffff); + + expect(packColor(vec3.fromValues(-1, 0, 0))).toEqual(0x000000); + expect(packColor(vec3.fromValues(0, 0.2, 2))).toEqual(0xff3300); + expect(packColor(vec3.fromValues(0.4, 4.4, -0.4))).toEqual(0x00ff66); + + expect(packColor(vec4.fromValues(0, 0, 0, 0))).toEqual(0x00000000); + expect(packColor(vec4.fromValues(0.2, 0, 1, 0.2))).toEqual(0x33ff0033); + expect(packColor(vec4.fromValues(0, 0.4, 1.0, 0.4))).toEqual(0x66ff6600); + expect(packColor(vec4.fromValues(0.6, 0.4, 0, 0.6))).toEqual(0x99006699); + expect(packColor(vec4.fromValues(1, 0.6, 0.8, 0.8))).toEqual(0xcccc99ff); + expect(packColor(vec4.fromValues(1, 1, 1, 1))).toEqual(0xffffffff); + + expect(packColor(vec4.fromValues(-1, 0, 0, -1))).toEqual(0x00000000); + expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); + expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); + }); +}); \ No newline at end of file