Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 81 additions & 16 deletions packages/phoenix-event-display/src/lib/models/cut.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { PrettySymbols } from '../../helpers/pretty-symbols';
import { ConfigRangeSlider } from '../../managers/ui-manager/phoenix-menu/config-types';

/**
* Plain-object representation of a Cut, safe for JSON serialization.
* Used by StateManager to persist active cut state across sessions.
*/
export interface CutJSON {
/** The event data attribute field name this cut applies to. */
field: string;
/** The active minimum bound value of the cut. */
minValue: number;
/** The active maximum bound value of the cut. */
maxValue: number;
/** Step size used by the range slider for this cut. */
step: number;
/** Whether the lower bound cut is currently active. */
minCutActive: boolean;
/** Whether the upper bound cut is currently active. */
maxCutActive: boolean;
}

/**
* Cut for specifying filters on event data attribute.
*/
Expand All @@ -13,17 +32,18 @@ export class Cut {
private defaultApplyMaxValue: boolean;
/** Default if lower bound applied */
private defaultApplyMinValue: boolean;

/** Range slider for Cut */
public configRangeSlider?: ConfigRangeSlider;

/**
* Create the cut to filter an event data attribute.
* @param field Name of the event data attribute to be filtered.
* @param minValue Minimum allowed value of the event data attribute.
* @param maxValue Maximum allowed value of the event data attribute.
* @param step Size of increment when using slider.
* @param minCutActive If true, the minimum cut is appled. Can be overriden later with enableMinCut.
* @param maxCutActive If true, the maximum cut is appled. Can be overriden later with enableMaxCut.
*
* @param field The event data attribute field name to filter on.
* @param minValue The minimum allowed value of the attribute.
* @param maxValue The maximum allowed value of the attribute.
* @param step Step size for the range slider.
* @param minCutActive Whether the lower bound cut is active by default.
* @param maxCutActive Whether the upper bound cut is active by default.
*/
constructor(
public field: string,
Expand All @@ -37,14 +57,17 @@ export class Cut {
this.defaultMaxValue = maxValue;
this.defaultApplyMinValue = minCutActive;
this.defaultApplyMaxValue = maxCutActive;

// Ensure all numeric values are actual numbers from the start
this.ensureNumericValues();
}

/** Returns true if upper cut is valid. */
/** Enable/disable upper cut */
enableMaxCut(check: boolean) {
this.maxCutActive = check;
}

/** Returns true if upper cut is valid. */
/** Enable/disable lower cut */
enableMinCut(check: boolean) {
this.minCutActive = check;
}
Expand All @@ -59,8 +82,7 @@ export class Cut {

/**
* Create a deep copy of this Cut with the same field, bounds, step,
* and active flags. The clone gets fresh defaults equal to the
* current values so that reset() works independently.
* and active flags.
*/
clone(): Cut {
return new Cut(
Expand All @@ -73,6 +95,48 @@ export class Cut {
);
}

/**
* Ensure minValue, maxValue and step are always real numbers
* This prevents string values like "0.5" from being saved in JSON.
*/
private ensureNumericValues() {
this.minValue = Number(this.minValue);
this.maxValue = Number(this.maxValue);
this.step = Number(this.step);
}

/**
* Serialize the current cut state to a plain object for JSON persistence.
* Forces numbers to prevent string values like "0.5".
*/
toJSON(): CutJSON {
this.ensureNumericValues(); // Extra safety before serialization

return {
field: this.field,
minValue: Number(this.minValue),
maxValue: Number(this.maxValue),
step: Number(this.step),
minCutActive: Boolean(this.minCutActive),
maxCutActive: Boolean(this.maxCutActive),
};
}

/**
* Reconstruct a Cut instance from a previously serialized CutJSON object.
* Handles cases where values might come as strings from JSON.parse().
*/
static fromJSON(json: CutJSON): Cut {
return new Cut(
json.field,
Number(json.minValue),
Number(json.maxValue),
Number(json.step ?? 1),
Boolean(json.minCutActive ?? true),
Boolean(json.maxCutActive ?? true),
);
}

/**
* Reset the minimum and maximum value of the cut to default.
*/
Expand All @@ -81,7 +145,9 @@ export class Cut {
this.maxValue = this.defaultMaxValue;
this.minCutActive = this.defaultApplyMinValue;
this.maxCutActive = this.defaultApplyMaxValue;
// Reset the config range slider

this.ensureNumericValues();

if (this.configRangeSlider != undefined) {
this.configRangeSlider.enableMin = true;
this.configRangeSlider.enableMax = true;
Expand All @@ -92,8 +158,6 @@ export class Cut {

/**
* Builds a config range slider for the cut to be used in Phoenix Menu
* @param collectionFiltering callback function to apply to all objects inside a collection, filtering them given a parameter
* @returns config range slider for the cut to be used in Phoenix Menu
*/
public getConfigRangeSlider(
collectionFiltering: () => void,
Expand All @@ -110,8 +174,9 @@ export class Cut {
enableMin: this.minCutActive,
enableMax: this.maxCutActive,
onChange: ({ value, highValue }) => {
this.minValue = value;
this.maxValue = highValue;
this.minValue = Number(value); // Force number
this.maxValue = Number(highValue); // Force number
this.ensureNumericValues();
collectionFiltering();
},
setEnableMin: (checked: boolean) => {
Expand Down
67 changes: 66 additions & 1 deletion packages/phoenix-event-display/src/managers/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { Camera } from 'three';
import { PhoenixMenuNode } from './ui-manager/phoenix-menu/phoenix-menu-node';
import { loadFile, saveFile } from '../helpers/file';
import { ActiveVariable } from '../helpers/active-variable';
import { Cut, CutJSON } from '../lib/models/cut.model';

/** Map of collection name to array of serialized cuts, for JSON persistence. */
export interface CutStateJSON {
[collectionName: string]: CutJSON[];
}
/**
* A singleton manager for managing the scene's state.
*/
Expand Down Expand Up @@ -78,7 +83,8 @@ export class StateManager {

/**
* Get the current state of the event display as a JSON object.
* @returns The state object with menu, camera, and clipping data.
* Now includes active cut state so Save State preserves filters.
* @returns The state object with menu, camera, clipping, and cut data.
*/
getStateAsJSON(): { [key: string]: any } {
const controls = this.eventDisplay
Expand All @@ -98,9 +104,38 @@ export class StateManager {
? this.openingClippingAngle.value
: null,
},
cuts: this.getActiveCutsAsJSON(),
};
}

/**
* Serialize active cuts from PhoenixMenuUI into a plain JSON-safe object.
* Only collections with at least one active cut bound are included.
* Returns an empty object if no cuts are active or UI is unavailable.
*/
private getActiveCutsAsJSON(): CutStateJSON {
const phoenixMenuUI = this.eventDisplay
?.getUIManager()
?.getPhoenixMenuUI?.();

if (!phoenixMenuUI) {
return {};
}

const registry = phoenixMenuUI.getCollectionCuts(); // This returns object { [name: string]: Cut[] }
const result: CutStateJSON = {};

Object.entries(registry).forEach(([collectionName, cuts]) => {
const activeCuts = cuts.filter(
(cut) => cut.minCutActive || cut.maxCutActive,
);
if (activeCuts.length > 0) {
result[collectionName] = activeCuts.map((cut) => cut.toJSON());
}
});

return result;
}
/**
* Save the state of the event display as JSON.
*/
Expand Down Expand Up @@ -169,6 +204,36 @@ export class StateManager {
}
}
}
// Restore cut state if present in the saved JSON
if (jsonData['cuts']) {
this.restoreCutsFromJSON(jsonData['cuts'] as CutStateJSON);
}
}
/**
* Deserialize cut state from a saved JSON file, register the cuts back
* into PhoenixMenuUI, and re-apply them to the currently loaded event.
* @param cutsJSON The cuts section of a loaded state JSON file.
*/
private restoreCutsFromJSON(cutsJSON: CutStateJSON): void {
const phoenixMenuUI = this.eventDisplay
?.getUIManager()
?.getPhoenixMenuUI?.();

if (!phoenixMenuUI) {
console.warn(
'StateManager: Cannot restore cuts — PhoenixMenuUI not available.',
);
return;
}

for (const [collectionName, cutJSONArray] of Object.entries(cutsJSON)) {
if (Array.isArray(cutJSONArray) && cutJSONArray.length > 0) {
const cuts = cutJSONArray.map((c) => Cut.fromJSON(c));
phoenixMenuUI.setCollectionCuts(collectionName, cuts);
}
}

phoenixMenuUI.reapplyCollectionCuts();
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/phoenix-event-display/src/managers/ui-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,16 @@ export class UIManager {
phoenixMenuUI?.loadEventFolderState();
phoenixMenuUI?.reapplyCollectionCuts();
}
/**
* Get the PhoenixMenuUI instance if one is active.
* Used by StateManager to access the cut registry for serialization.
* @returns The PhoenixMenuUI instance, or undefined if not initialized.
*/
public getPhoenixMenuUI(): PhoenixMenuUI | undefined {
return this.uiMenus.find((uiMenu) => uiMenu instanceof PhoenixMenuUI) as
| PhoenixMenuUI
| undefined;
}

/**
* Get all the UI menus.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,15 @@ export class PhoenixMenuUI implements PhoenixUI<PhoenixMenuNode> {

this.addDrawOptions(collectionNode, collectionName);

if (cuts && cuts.length > 0) {
// Always register cuts for this collection if any were provided.
// This ensures the registry has the Cut instances so StateManager
// can serialize active cuts (minCutActive / maxCutActive) even if
// the initial cuts array was empty or the user only moves sliders later.
if (cuts !== undefined) {
this.collectionCuts[collectionName] = cuts;
this.addCutOptions(collectionNode, collectionName, cuts);
if (cuts.length > 0) {
this.addCutOptions(collectionNode, collectionName, cuts);
}
}

const colorByOptions: ColorByOptionKeys[] = [];
Expand Down Expand Up @@ -541,4 +547,23 @@ export class PhoenixMenuUI implements PhoenixUI<PhoenixMenuNode> {
this.sceneManager.collectionFilter(collectionName, cuts);
}
}

/**
* Returns the active cut registry keyed by collection name.
* Used by StateManager for cut state serialization on Save State.
* @returns A reference to the collectionCuts registry.
*/
public getCollectionCuts(): { [collectionName: string]: Cut[] } {
return this.collectionCuts;
}

/**
* Set cuts for a specific collection in the registry.
* Used by StateManager when restoring cut state from Load State.
* @param collectionName Name of the collection.
* @param cuts Array of Cut instances to register.
*/
public setCollectionCuts(collectionName: string, cuts: Cut[]): void {
this.collectionCuts[collectionName] = cuts;
}
}
Loading
Loading