diff --git a/backend/node_menu.py b/backend/node_menu.py index d12c127..0ac9274 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -12,18 +12,19 @@ from typing import Any MENU_LAYOUT: dict[str, list[str]] = { - "Add": [ + "Input": [ "Image", "ImageDemo", "Folder", - "ColorMap", "Number", "RangeSlider", "Coordinate", "CoordinatePair", - "Font", ], - "Output": [ + "Display": [ + "ColorMap", + "Font", + "ColormapAdjust", "PreviewImage", "ValueDisplay", "View3D", @@ -36,11 +37,10 @@ MENU_LAYOUT: dict[str, list[str]] = { "Annotations", "AngleMeasure", ], - "Modify": [ + "Geometry": [ "CropResizeField", "RotateField", "FlipField", - "ColormapAdjust", ], "Filter": [ "GaussianFilter", @@ -50,25 +50,31 @@ MENU_LAYOUT: dict[str, list[str]] = { "FFTFilter2D", "ScarRemoval", ], - "Frequency": [ - "PSDF", + "Spectral": [ "FFT2D", "InverseFFT2D", + "FFTFilter1D", + "FFTFilter2D", + "ACF", + "PSDF", ], - "Flatten": [ + "Level & Correct": [ "FixZero", - "LineCorrection", "PlaneLevelField", "PolyLevelField", "FacetLevelField", + "LineCorrection", + "ScarRemoval", ], "Measure": [ + "AngleMeasure", "CrossSection", "Histogram", "Cursors", "Curvature", "FractalDimension", "ACF", + "PSDF", "Statistics", "Stats", ], @@ -78,12 +84,14 @@ MENU_LAYOUT: dict[str, list[str]] = { "MaskMorphology", "MaskInvert", "MaskOperations", - ], - "Grains": [ - "GrainAnalysis", "GrainDistanceTransform", "WatershedSegmentation", ], + "Grains": [ + "GrainDistanceTransform", + "WatershedSegmentation", + "GrainAnalysis", + ], } @@ -91,11 +99,17 @@ _CATEGORY_ORDER = {category: index for index, category in enumerate(MENU_LAYOUT) _NODE_METADATA: dict[str, dict[str, Any]] = {} for category, class_names in MENU_LAYOUT.items(): for node_order, class_name in enumerate(class_names): - _NODE_METADATA[class_name] = { + metadata = _NODE_METADATA.setdefault(class_name, { "category": category, "category_order": _CATEGORY_ORDER[category], "menu_order": node_order, - } + "menu_categories": [], + }) + metadata["menu_categories"].append({ + "category": category, + "category_order": _CATEGORY_ORDER[category], + "menu_order": node_order, + }) def get_menu_metadata(class_name: str) -> dict[str, Any]: @@ -107,4 +121,9 @@ def get_menu_metadata(class_name: str) -> dict[str, Any]: "category": "Unsorted", "category_order": len(_CATEGORY_ORDER), "menu_order": 10_000, + "menu_categories": [{ + "category": "Unsorted", + "category_order": len(_CATEGORY_ORDER), + "menu_order": 10_000, + }], } diff --git a/backend/node_registry.py b/backend/node_registry.py index 022d661..c1b99da 100644 --- a/backend/node_registry.py +++ b/backend/node_registry.py @@ -47,6 +47,7 @@ def get_node_info(class_name: str) -> dict[str, Any]: "category": menu_metadata["category"], "category_order": menu_metadata["category_order"], "menu_order": menu_metadata["menu_order"], + "menu_categories": list(menu_metadata.get("menu_categories", [])), "input": input_types, "input_order": {k: list(v.keys()) for k, v in input_types.items()}, "output": list(cls.RETURN_TYPES), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bd15a00..661f932 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -404,8 +404,16 @@ function canStartCanvasRightDragZoom(target) { } function compareMenuNodes(a, b) { - const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER; - const orderB = Number.isFinite(b?.def?.menu_order) ? b.def.menu_order : Number.MAX_SAFE_INTEGER; + const orderA = Number.isFinite(a?.menu_order) + ? a.menu_order + : Number.isFinite(a?.def?.menu_order) + ? a.def.menu_order + : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b?.menu_order) + ? b.menu_order + : Number.isFinite(b?.def?.menu_order) + ? b.def.menu_order + : Number.MAX_SAFE_INTEGER; if (orderA !== orderB) return orderA - orderB; const nameA = (a?.def?.display_name || a?.className || '').toLowerCase(); @@ -615,19 +623,37 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti if (!hasMatch) continue; } } - const cat = def.category || 'uncategorized'; - if (!cats[cat]) { - cats[cat] = { - name: cat, - order: Number.isFinite(def.category_order) ? def.category_order : Number.MAX_SAFE_INTEGER, - items: [], - }; + const menuCategories = Array.isArray(def.menu_categories) && def.menu_categories.length > 0 + ? def.menu_categories + : [{ + category: def.category || 'uncategorized', + category_order: def.category_order, + menu_order: def.menu_order, + }]; + + for (const menuCategory of menuCategories) { + const cat = menuCategory?.category || def.category || 'uncategorized'; + if (!cats[cat]) { + cats[cat] = { + name: cat, + order: Number.isFinite(menuCategory?.category_order) + ? menuCategory.category_order + : Number.MAX_SAFE_INTEGER, + items: [], + }; + } + cats[cat].order = Math.min( + cats[cat].order, + Number.isFinite(menuCategory?.category_order) + ? menuCategory.category_order + : Number.MAX_SAFE_INTEGER, + ); + cats[cat].items.push({ + className, + def, + menu_order: Number.isFinite(menuCategory?.menu_order) ? menuCategory.menu_order : def.menu_order, + }); } - cats[cat].order = Math.min( - cats[cat].order, - Number.isFinite(def.category_order) ? def.category_order : Number.MAX_SAFE_INTEGER, - ); - cats[cat].items.push({ className, def }); } return Object.values(cats) .map((category) => ({ @@ -642,10 +668,15 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti if (!search.trim()) return null; const q = search.toLowerCase(); const results = []; + const seen = new Set(); for (const category of categories) { for (const { className, def } of category.items) { + if (seen.has(className)) continue; const name = (def.display_name || className).toLowerCase(); - if (name.includes(q)) results.push({ className, def }); + if (name.includes(q)) { + results.push({ className, def }); + seen.add(className); + } } } return results; diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 719c7f7..3ec208a 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -33,13 +33,16 @@ export const TYPE_COLORS = { }; export const CAT_COLORS = { - io: '#37474f', - filters: '#1a237e', - modify: '#0f766e', - level: '#1b5e20', - analysis: '#4a148c', + Input: '#37474f', + Display: '#212121', + Overlay: '#0f766e', + Geometry: '#0d9488', + Filter: '#1a237e', + Spectral: '#4c1d95', + 'Level & Correct': '#1b5e20', + Measure: '#4a148c', + Mask: '#7c2d12', Grains: '#bf360c', - display: '#212121', }; export const SOCKET_COMPATIBILITY = { diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 46f7e27..ca15e78 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -287,7 +287,7 @@ def test_flip_field(): overlays=[markup_overlay, annotation_overlay], ) - assert get_node_info("FlipField")["category"] == "Modify" + assert get_node_info("FlipField")["category"] == "Geometry" flipped_x, = node.process(field, axis="x") assert np.array_equal(flipped_x.data, np.flipud(data)) @@ -569,7 +569,7 @@ def test_facet_level(): node = FacetLevelField() plane_node = PlaneLevelField() - assert get_node_info("FacetLevelField")["category"] == "Flatten" + assert get_node_info("FacetLevelField")["category"] == "Level & Correct" N = 96 yy, xx = np.mgrid[0:N, 0:N] @@ -749,7 +749,7 @@ def test_line_correction(): from backend.nodes.line_correction import LineCorrection node = LineCorrection() - assert get_node_info("LineCorrection")["category"] == "Flatten" + assert get_node_info("LineCorrection")["category"] == "Level & Correct" rows = 96 cols = 128 @@ -815,7 +815,9 @@ def test_scar_removal(): from backend.nodes.scar_removal import ScarRemoval node = ScarRemoval() - assert get_node_info("ScarRemoval")["category"] == "Filter" + info = get_node_info("ScarRemoval") + assert info["category"] == "Filter" + assert {entry["category"] for entry in info["menu_categories"]} == {"Filter", "Level & Correct"} rows = 96 cols = 128 @@ -874,7 +876,9 @@ def test_angle_measure(): from backend.data_types import ImageData node = AngleMeasure() - assert get_node_info("AngleMeasure")["category"] == "Overlay" + info = get_node_info("AngleMeasure") + assert info["category"] == "Overlay" + assert {entry["category"] for entry in info["menu_categories"]} == {"Overlay", "Measure"} required_inputs = AngleMeasure.INPUT_TYPES()["required"] optional_inputs = AngleMeasure.INPUT_TYPES().get("optional", {}) assert required_inputs["color"][1]["default"] == "#ff9800"