diff --git a/package-lock.json b/package-lock.json
index c6ef272..1d57458 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,7 +74,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -686,7 +685,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz",
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1570,7 +1568,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz",
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-module-imports": "^7.27.1",
@@ -4533,7 +4530,8 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/q": {
"version": "1.5.8",
@@ -4760,7 +4758,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -5145,7 +5142,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5244,7 +5240,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6168,7 +6163,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7241,7 +7235,8 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -8095,7 +8090,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10909,7 +10903,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@@ -13589,7 +13582,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -14826,7 +14818,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -14950,7 +14941,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -16097,7 +16087,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -16446,7 +16435,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -16597,7 +16585,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -16637,7 +16624,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -17175,7 +17161,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT",
- "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -17421,7 +17406,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -19040,7 +19024,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -19470,7 +19453,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.0.tgz",
"integrity": "sha512-5DeICTX8BVgNp6afSPYXAFjskIgWGlygQH58bcozPOXgo2r/6xx39Y1+cULZ3gTxUYQP88jmwLj2anu4Xaq84g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -19542,7 +19524,6 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@@ -19952,7 +19933,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
diff --git a/red_green_playground.py b/red_green_playground.py
index e8b18d9..105aa9c 100644
--- a/red_green_playground.py
+++ b/red_green_playground.py
@@ -32,6 +32,14 @@
# Flask app initialization
app = Flask(__name__, static_folder=os.path.join(build_path, "static"))
+
+@app.after_request
+def add_cors_headers(response):
+ response.headers["Access-Control-Allow-Origin"] = "*"
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
+ return response
+
def get_anim(frames, framerate=30, skip_t = 1):
"""
frames: list of N np.arrays (H x W x 3)
diff --git a/src/App.js b/src/App.js
index aeaf0f9..392d73c 100644
--- a/src/App.js
+++ b/src/App.js
@@ -3,6 +3,7 @@ import VideoPlayer from "./components/VideoPlayer";
import NavigationBar from "./components/playground/NavigationBar";
import SimulationSettingsPanel from "./components/playground/SimulationSettingsPanel";
import SceneControlsPanel from "./components/playground/SceneControlsPanel";
+import SelectedEntityPanel from "./components/playground/SelectedEntityPanel";
import TrajectoryScrubPanel from "./components/playground/TrajectoryScrubPanel";
import OcclusionPresetsPanel from "./components/playground/OcclusionPresetsPanel";
import DistractorControlsPanel from "./components/playground/DistractorControlsPanel";
@@ -21,6 +22,7 @@ import { DEFAULT_RANDOM_DISTRACTOR_PARAMS, VID_RES, PX_SCALE, INTERVAL, BORDER_P
function App() {
const videoPlayerRef = useRef(null);
+ const handleSimulateRef = useRef(null);
// Simulation parameters
const [videoLength, setVideoLength] = useState(10);
@@ -52,6 +54,7 @@ function App() {
// Trajectory scrub state
const [scrubEnabled, setScrubEnabled] = useState(false);
const [scrubFrame, setScrubFrame] = useState(0);
+ const [selectedEntityId, setSelectedEntityId] = useState(null);
// Use hooks for state management
const entitiesHook = useEntities(worldWidth, worldHeight);
@@ -67,7 +70,8 @@ function App() {
const { targetDirection, setTargetDirection, directionInput, setDirectionInput, updateTargetDirection, handleDirectionInputChange } = targetDirectionHook;
const sceneTransformHook = useSceneTransform(entities, setEntities, worldWidth, worldHeight, movementUnit, setTargetDirection, setDirectionInput);
- const { moveScene, rotateScene } = sceneTransformHook;
+ const { moveScene, rotateScene, mirrorScene } = sceneTransformHook;
+ const selectedEntity = entities.find((entity) => entity.id === selectedEntityId) || null;
// Keyboard event listener for arrow keys
useEffect(() => {
@@ -79,12 +83,27 @@ function App() {
e.preventDefault();
moveScene(e.key);
}
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEntityId !== null) {
+ e.preventDefault();
+ deleteEntity(selectedEntityId);
+ setSelectedEntityId(null);
+ }
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
- }, [moveScene]);
+ }, [moveScene, selectedEntityId, deleteEntity]);
+
+ useEffect(() => {
+ if (selectedEntityId === null) {
+ return;
+ }
+ const selectedStillExists = entities.some((entity) => entity.id === selectedEntityId);
+ if (!selectedStillExists) {
+ setSelectedEntityId(null);
+ }
+ }, [entities, selectedEntityId]);
// Derive effective occluders after applying windows, and validate overlaps against that.
const { entitiesForSimulation, occluderPieces } = getEntitiesWithWindowsApplied(entities);
@@ -112,6 +131,7 @@ function App() {
// Wrapper for clearAllEntities to also clear simData and distractor data
const handleClearAll = () => {
clearAllEntities();
+ setSelectedEntityId(null);
setSimData(null);
resetDistractorParams();
};
@@ -141,6 +161,30 @@ function App() {
setScrubFrame(0);
handleSimulateBase(entitiesForSimulation, simulationParams, mode, keyDistractors, randomDistractorParams, autoRun);
};
+ handleSimulateRef.current = handleSimulate;
+
+ useEffect(() => {
+ const onKeyDown = (e) => {
+ const isEnterKey =
+ e.key === "Enter" ||
+ e.key === "Return" ||
+ e.code === "Enter" ||
+ e.code === "NumpadEnter";
+ const hasShortcutModifier = e.metaKey || e.ctrlKey;
+
+ if (!isEnterKey || !hasShortcutModifier || e.isComposing) {
+ return;
+ }
+ if (!(isValidPhysics && overlapValidation.valid)) {
+ return;
+ }
+ e.preventDefault();
+ handleSimulateRef.current?.(false);
+ };
+ // Capture phase makes shortcut more reliable when focused controls stop bubbling.
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [isValidPhysics, overlapValidation.valid]);
// File operation handlers
const handleFileLoad = createFileLoadHandler({
@@ -157,8 +201,9 @@ function App() {
setTargetDirection,
setDirectionInput,
setShouldAutoSimulate,
+ setTrial_name,
mode,
- DEFAULT_RANDOM_DISTRACTOR_PARAMS
+ DEFAULT_RANDOM_DISTRACTOR_PARAMS,
});
const handleSetSaveDirectory = createSetSaveDirectoryHandler(setSaveDirectoryHandle);
@@ -206,10 +251,6 @@ function App() {
}
};
- const handleEntityDrag = (entity, d) => {
- updateEntityFromDrag(entity, d);
- };
-
const handleEntityDragStop = (entity, d) => {
updateEntityFromDrag(entity, d);
};
@@ -241,10 +282,6 @@ function App() {
updateEntity(entity.id, updatedEntity);
};
- const handleEntityResize = (entity, ref, position) => {
- updateEntityFromResize(entity, ref, position);
- };
-
const handleEntityResizeStop = (entity, ref, position) => {
updateEntityFromResize(entity, ref, position);
};
@@ -332,10 +369,42 @@ function App() {
const handleCanvasClick = () => {
setContextMenu({ visible: false, x: 0, y: 0, entityId: null });
+ setSelectedEntityId(null);
};
const handleDeleteEntity = (id) => {
deleteEntity(id);
+ if (id === selectedEntityId) {
+ setSelectedEntityId(null);
+ }
+ };
+
+ const handleSelectedEntityFieldChange = (field, value) => {
+ if (!selectedEntity) return;
+ const numericValue = Number(value);
+ if (Number.isNaN(numericValue)) return;
+
+ if (field === "directionDegrees" && selectedEntity.type === "target") {
+ handleUpdateTargetDirection(numericValue);
+ return;
+ }
+
+ const nextEntity = { ...selectedEntity };
+
+ if (field === "width" && selectedEntity.type !== "target") {
+ nextEntity.width = Math.max(INTERVAL, numericValue);
+ } else if (field === "height" && selectedEntity.type !== "target") {
+ nextEntity.height = Math.max(INTERVAL, numericValue);
+ } else if (field === "x" || field === "y") {
+ nextEntity[field] = numericValue;
+ } else {
+ return;
+ }
+
+ nextEntity.x = Math.max(0, Math.min(nextEntity.x, worldWidth - nextEntity.width));
+ nextEntity.y = Math.max(0, Math.min(nextEntity.y, worldHeight - nextEntity.height));
+
+ updateEntity(selectedEntity.id, nextEntity);
};
const handleUpdateTargetDirection = (angleDegrees) => {
@@ -425,9 +494,16 @@ function App() {
movementUnit={movementUnit}
onMovementUnitChange={setMovementUnit}
onRotateScene={rotateScene}
+ onMirrorScene={mirrorScene}
hasEntities={entities.length > 0}
/>
+
+
{/* Video Player Section */}
diff --git a/src/components/playground/ControlBar.js b/src/components/playground/ControlBar.js
index 41105de..f9a8e98 100644
--- a/src/components/playground/ControlBar.js
+++ b/src/components/playground/ControlBar.js
@@ -227,7 +227,11 @@ const ControlBar = ({
e.target.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.1)";
}
}}
- title={overlapWarning || undefined}
+ title={
+ overlapWarning
+ ? `${overlapWarning} — Keyboard: ⌘+Enter or Ctrl+Enter when scene is valid.`
+ : "Keyboard: ⌘+Enter (Mac) or Ctrl+Enter (Windows/Linux) to simulate"
+ }
>
{isValidPhysics ? "🚀 Simulate" : (overlapWarning ? "⚠️ Overlaps Detected" : "Invalid Physics")}
@@ -443,6 +447,7 @@ const ControlBar = ({
fontWeight: "600",
transition: "all 0.2s ease",
margin: 0,
+ border: "none",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
display: "inline-block"
}}
diff --git a/src/components/playground/EntityCanvas.js b/src/components/playground/EntityCanvas.js
index 7694e31..fc85c5e 100644
--- a/src/components/playground/EntityCanvas.js
+++ b/src/components/playground/EntityCanvas.js
@@ -17,7 +17,9 @@ const EntityCanvas = ({
onDeleteEntity,
onUpdateTargetDirection,
updateEntity,
- overlapRegions = []
+ overlapRegions = [],
+ selectedEntityId,
+ onEntitySelect
}) => {
const px_scale = PX_SCALE;
const border_px = BORDER_PX;
@@ -175,10 +177,24 @@ const EntityCanvas = ({
: entity.type === ENTITY_TYPES.OCCLUDER
? "1px dashed rgba(15,23,42,0.6)"
: "0px solid black",
+ outline: selectedEntityId === entity.id ? "2px solid #2563eb" : "none",
+ outlineOffset: selectedEntityId === entity.id ? "2px" : "0px",
cursor: "move",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)"
}}
- onContextMenu={(e) => onEntityContextMenu(e, entity.id)}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ onEntitySelect(entity.id);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEntitySelect(entity.id);
+ }}
+ onContextMenu={(e) => {
+ e.stopPropagation();
+ onEntitySelect(entity.id);
+ onEntityContextMenu(e, entity.id);
+ }}
/>
{entity.type === "target" && renderDirectionPreview(entity)}
diff --git a/src/components/playground/SceneControlsPanel.js b/src/components/playground/SceneControlsPanel.js
index 54fa56b..7288568 100644
--- a/src/components/playground/SceneControlsPanel.js
+++ b/src/components/playground/SceneControlsPanel.js
@@ -1,6 +1,6 @@
import React from 'react';
-const SceneControlsPanel = ({ movementUnit, onMovementUnitChange, onRotateScene, hasEntities }) => {
+const SceneControlsPanel = ({ movementUnit, onMovementUnitChange, onRotateScene, onMirrorScene, hasEntities }) => {
return (
+
+
+
+
+
+
+
+
+ Mirror all elements across scene midlines
+
+
);
};
diff --git a/src/components/playground/SelectedEntityPanel.js b/src/components/playground/SelectedEntityPanel.js
new file mode 100644
index 0000000..0169389
--- /dev/null
+++ b/src/components/playground/SelectedEntityPanel.js
@@ -0,0 +1,217 @@
+import React, { useEffect, useState } from 'react';
+import { ENTITY_TYPES } from '../../constants';
+
+const inputStyle = {
+ width: '100%',
+ padding: '8px 12px',
+ border: '2px solid #e5e7eb',
+ borderRadius: '6px',
+ fontSize: '12px',
+ outline: 'none',
+ boxSizing: 'border-box',
+ backgroundColor: '#ffffff',
+ color: '#1f2937'
+};
+
+const labelStyle = {
+ display: 'block',
+ fontSize: '12px',
+ fontWeight: '600',
+ color: '#374151',
+ marginBottom: '6px'
+};
+
+const SelectedEntityPanel = ({ selectedEntity, onChangeField, onDeleteEntity }) => {
+ const [draftValues, setDraftValues] = useState({
+ x: '',
+ y: '',
+ width: '',
+ height: '',
+ directionDegrees: ''
+ });
+
+ useEffect(() => {
+ if (!selectedEntity) {
+ setDraftValues({
+ x: '',
+ y: '',
+ width: '',
+ height: '',
+ directionDegrees: ''
+ });
+ return;
+ }
+
+ setDraftValues({
+ x: String(selectedEntity.x),
+ y: String(selectedEntity.y),
+ width: String(selectedEntity.width),
+ height: String(selectedEntity.height),
+ directionDegrees: ((selectedEntity.direction || 0) * (180 / Math.PI)).toFixed(2)
+ });
+ }, [selectedEntity]);
+
+ const handleInputChange = (field, rawValue) => {
+ setDraftValues((prev) => ({
+ ...prev,
+ [field]: rawValue
+ }));
+
+ if (rawValue === '') {
+ return;
+ }
+
+ const parsed = Number(rawValue);
+ if (Number.isNaN(parsed)) {
+ return;
+ }
+
+ onChangeField(field, rawValue);
+ };
+
+ const handleInputBlur = (field) => {
+ if (!selectedEntity) return;
+ if (draftValues[field] !== '') return;
+
+ const resetValue = field === 'directionDegrees'
+ ? ((selectedEntity.direction || 0) * (180 / Math.PI)).toFixed(2)
+ : String(selectedEntity[field] ?? '');
+
+ setDraftValues((prev) => ({
+ ...prev,
+ [field]: resetValue
+ }));
+ };
+
+ return (
+
+
+ Selected Object
+
+
+ {!selectedEntity ? (
+
+ Click an object on the canvas to edit its numeric parameters.
+
+ ) : (
+ <>
+
+ Type: {selectedEntity.type}
+
+
+
+
+ {selectedEntity.type !== ENTITY_TYPES.TARGET && (
+
+ )}
+
+ {selectedEntity.type === ENTITY_TYPES.TARGET && (
+
+
+ handleInputChange('directionDegrees', e.target.value)}
+ onBlur={() => handleInputBlur('directionDegrees')}
+ style={inputStyle}
+ />
+
+ )}
+
+
+ >
+ )}
+
+ );
+};
+
+export default SelectedEntityPanel;
diff --git a/src/hooks/useSceneTransform.js b/src/hooks/useSceneTransform.js
index bf1b44b..0d4881d 100644
--- a/src/hooks/useSceneTransform.js
+++ b/src/hooks/useSceneTransform.js
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { clamp } from '../utils/sceneUtils';
/**
- * Hook for scene transformation operations (move, rotate)
+ * Hook for scene transformation operations (move, rotate, mirror)
*/
export const useSceneTransform = (entities, setEntities, worldWidth, worldHeight, movementUnit, setTargetDirection, setDirectionInput) => {
const moveScene = useCallback((direction) => {
@@ -157,9 +157,76 @@ export const useSceneTransform = (entities, setEntities, worldWidth, worldHeight
});
}, [entities.length, worldWidth, worldHeight, setEntities, setTargetDirection, setDirectionInput]);
+ const mirrorScene = useCallback((axis) => {
+ if (entities.length === 0) return;
+
+ const centerX = worldWidth / 2;
+ const centerY = worldHeight / 2;
+ const isHorizontal = axis === 'horizontal';
+ const isVertical = axis === 'vertical';
+ if (!isHorizontal && !isVertical) return;
+
+ setEntities((prevEntities) => {
+ const PRECISION = 1e6;
+ const updatedEntities = prevEntities.map((entity) => {
+ const entityCenterX = entity.x + entity.width / 2;
+ const entityCenterY = entity.y + entity.height / 2;
+
+ const mirroredCenterX = isVertical ? (2 * centerX - entityCenterX) : entityCenterX;
+ const mirroredCenterY = isHorizontal ? (2 * centerY - entityCenterY) : entityCenterY;
+
+ const rawX = Math.max(0, Math.min(mirroredCenterX - entity.width / 2, worldWidth - entity.width));
+ const rawY = Math.max(0, Math.min(mirroredCenterY - entity.height / 2, worldHeight - entity.height));
+ const newX = Math.round(rawX * PRECISION) / PRECISION;
+ const newY = Math.round(rawY * PRECISION) / PRECISION;
+
+ const updatedEntity = {
+ id: entity.id,
+ type: entity.type,
+ x: newX,
+ y: newY,
+ width: entity.width,
+ height: entity.height,
+ };
+
+ if (entity.type === 'target' || entity.direction !== undefined) {
+ const currentDirection = entity.direction || 0;
+ let newDirection = currentDirection;
+ if (isHorizontal) {
+ // Reflect across horizontal midline: (x, y) -> (x, -y), so angle -> -angle.
+ newDirection = -currentDirection;
+ } else if (isVertical) {
+ // Reflect across vertical midline: (x, y) -> (-x, y), so angle -> pi - angle.
+ newDirection = Math.PI - currentDirection;
+ }
+ while (newDirection > Math.PI) newDirection -= 2 * Math.PI;
+ while (newDirection < -Math.PI) newDirection += 2 * Math.PI;
+ updatedEntity.direction = newDirection;
+ }
+
+ Object.keys(entity).forEach((key) => {
+ if (!['id', 'type', 'x', 'y', 'width', 'height', 'direction'].includes(key)) {
+ updatedEntity[key] = entity[key];
+ }
+ });
+
+ return updatedEntity;
+ });
+
+ const targetEntity = updatedEntities.find((e) => e.type === 'target');
+ if (targetEntity && setTargetDirection && setDirectionInput) {
+ setTargetDirection(targetEntity.direction || 0);
+ setDirectionInput(((targetEntity.direction || 0) * (180 / Math.PI)).toString());
+ }
+
+ return updatedEntities;
+ });
+ }, [entities.length, worldWidth, worldHeight, setEntities, setTargetDirection, setDirectionInput]);
+
return {
moveScene,
rotateScene,
+ mirrorScene,
};
};
diff --git a/src/hooks/useSimulation.js b/src/hooks/useSimulation.js
index 88eb2dd..dd35572 100644
--- a/src/hooks/useSimulation.js
+++ b/src/hooks/useSimulation.js
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { validateBallPositions } from '../utils/collisionUtils';
+import { postJson, postNoBody } from '../utils/apiUtils';
/**
* Hook for simulation management
@@ -39,13 +40,7 @@ export const useSimulation = () => {
}
try {
- const response = await fetch('/simulate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(requestBody),
- });
-
- const data = await response.json();
+ const data = await postJson('/simulate', requestBody);
if (data.status === 'success') {
setSimData(data.sim_data);
} else {
@@ -56,16 +51,14 @@ export const useSimulation = () => {
} catch (error) {
if (!autoRun) {
console.error('Error during simulation:', error);
+ alert(`Simulation failed: ${error.message}`);
}
}
}, []);
const clearSimulation = useCallback(async () => {
try {
- await fetch('/clear_simulation', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- });
+ await postNoBody('/clear_simulation');
setSimData(null);
} catch (error) {
console.error('Error clearing simulation:', error);
diff --git a/src/utils/apiUtils.js b/src/utils/apiUtils.js
new file mode 100644
index 0000000..9c94d3a
--- /dev/null
+++ b/src/utils/apiUtils.js
@@ -0,0 +1,58 @@
+const runtimeDefaultApiBase = (() => {
+ if (process.env.REACT_APP_API_BASE_URL) {
+ return process.env.REACT_APP_API_BASE_URL;
+ }
+
+ if (typeof window !== 'undefined' && window.location.hostname === 'localhost' && window.location.port !== '5001') {
+ return 'http://localhost:5001';
+ }
+
+ return '';
+})();
+
+const buildUrl = (path) => `${runtimeDefaultApiBase}${path}`;
+
+const readResponseErrorText = async (response) => {
+ const text = await response.text();
+ if (!text) {
+ return `Request failed with status ${response.status}`;
+ }
+
+ if (text.includes('')) {
+ return 'Backend API endpoint not found. Please run the Flask backend (uv run python red_green_playground.py).';
+ }
+
+ return text;
+};
+
+export const postJson = async (path, body) => {
+ const response = await fetch(buildUrl(path), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body ?? {})
+ });
+
+ if (!response.ok) {
+ throw new Error(await readResponseErrorText(response));
+ }
+
+ try {
+ return await response.json();
+ } catch (error) {
+ throw new Error('Received an invalid JSON response from backend.');
+ }
+};
+
+export const postNoBody = async (path) => {
+ const response = await fetch(buildUrl(path), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ });
+
+ if (!response.ok) {
+ throw new Error(await readResponseErrorText(response));
+ }
+
+ return response;
+};
+
diff --git a/src/utils/fileUtils.js b/src/utils/fileUtils.js
index 8cb016a..56ec7df 100644
--- a/src/utils/fileUtils.js
+++ b/src/utils/fileUtils.js
@@ -5,6 +5,7 @@
* @param {Array} keyDistractors - Key distractors array
* @param {Object} randomDistractorParams - Random distractor parameters
* @param {Object} simulationParams - Simulation parameters
+ * @param {string} trialName - Trial name for this scene
* @returns {Object} Init state data object
*/
export const prepareInitStateData = (
@@ -12,11 +13,13 @@ export const prepareInitStateData = (
mode,
keyDistractors,
randomDistractorParams,
- simulationParams
+ simulationParams,
+ trialName
) => {
const initStateData = {
entities: entities,
- simulationParams: simulationParams
+ simulationParams: simulationParams,
+ trialName: trialName
};
if (mode === 'distractor' && (keyDistractors.length > 0 || randomDistractorParams.probability > 0)) {
@@ -46,16 +49,72 @@ export const downloadJSON = (data, filename) => {
URL.revokeObjectURL(url);
};
+export const SCENE_ENTITIES_FILENAME = 'init_state_entities.json';
+
+/**
+ * Derive trial name from a loaded scene file.
+ * Prefer parent folder name when loading `init_state_entities.json`.
+ * Falls back to parsing the filename when folder metadata is unavailable.
+ * @param {File} file - Loaded scene file
+ * @returns {string} Trial name for the UI and save paths
+ */
+export const trialNameFromLoadedFile = (file) => {
+ const filename = file?.name || '';
+
+ // Best case: relative path is available (e.g. some browser contexts).
+ const relativePath = String(file?.webkitRelativePath || '').replace(/\\/g, '/');
+ if (relativePath) {
+ const parts = relativePath.split('/').filter(Boolean);
+ if (parts.length >= 2 && parts[parts.length - 1] === SCENE_ENTITIES_FILENAME) {
+ return parts[parts.length - 2];
+ }
+ }
+
+ // Some runtimes expose absolute path on File objects (non-standard).
+ const absolutePath = String(file?.path || '').replace(/\\/g, '/');
+ if (absolutePath) {
+ const parts = absolutePath.split('/').filter(Boolean);
+ if (parts.length >= 2 && parts[parts.length - 1] === SCENE_ENTITIES_FILENAME) {
+ return parts[parts.length - 2];
+ }
+ }
+
+ const base = String(filename)
+ .replace(/^.*[/\\]/, '')
+ .replace(/\.json$/i, '');
+
+ const knownSuffixes = [
+ '_init_state_entities',
+ '_simulation_data',
+ '_init_state',
+ ];
+
+ let name = base;
+ for (const suffix of knownSuffixes) {
+ if (name.toLowerCase().endsWith(suffix.toLowerCase())) {
+ name = name.slice(0, -suffix.length);
+ break;
+ }
+ }
+
+ if (name === 'init_state_entities' || name === 'simulation_data') {
+ return 'base';
+ }
+
+ return name.trim() || 'base';
+};
+
/**
* Parse loaded file data
* @param {Object|Array} parsedData - Parsed JSON data
- * @returns {{entities: Array, hasDistractorData: boolean, distractorData?: Object, simulationParams?: Object}}
+ * @returns {{entities: Array, hasDistractorData: boolean, distractorData?: Object, simulationParams?: Object, trialName?: string}}
*/
export const parseFileData = (parsedData) => {
let parsedEntities;
let hasDistractorData = false;
let distractorData = null;
let simulationParams = null;
+ let trialName = null;
if (Array.isArray(parsedData)) {
// Old format - just entities
@@ -67,6 +126,9 @@ export const parseFileData = (parsedData) => {
if (parsedData.simulationParams) {
simulationParams = parsedData.simulationParams;
}
+ if (typeof parsedData.trialName === 'string' && parsedData.trialName.trim()) {
+ trialName = parsedData.trialName.trim();
+ }
if (parsedData.distractorData || parsedData.hallucinationData) {
hasDistractorData = true;
@@ -85,7 +147,8 @@ export const parseFileData = (parsedData) => {
entities: parsedEntities,
hasDistractorData,
distractorData,
- simulationParams
+ simulationParams,
+ trialName
};
};
@@ -109,64 +172,63 @@ export const createFileLoadHandler = ({
setTargetDirection,
setDirectionInput,
setShouldAutoSimulate,
+ setTrial_name,
mode,
DEFAULT_RANDOM_DISTRACTOR_PARAMS
}) => {
return async (event) => {
const file = event.target.files[0];
+ event.target.value = '';
if (file) {
try {
- const clearResponse = await fetch("/clear_simulation", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- });
- if (!clearResponse.ok) {
- alert(`Failed to clear simulation: ${await clearResponse.text()}`);
- return;
- }
+ // Clear client-side simulation state before loading new scene data.
setSimData(null);
- const reader = new FileReader();
- reader.onload = (e) => {
- try {
- const parsedData = JSON.parse(e.target.result);
- const { entities: parsedEntities, hasDistractorData, distractorData, simulationParams } = parseFileData(parsedData);
- if (simulationParams) {
- if (simulationParams.videoLength !== undefined) setVideoLength(simulationParams.videoLength);
- if (simulationParams.ballSpeed !== undefined) setBallSpeed(simulationParams.ballSpeed);
- if (simulationParams.fps !== undefined) setFps(simulationParams.fps);
- if (simulationParams.worldWidth !== undefined) setWorldWidth(simulationParams.worldWidth);
- if (simulationParams.worldHeight !== undefined) setWorldHeight(simulationParams.worldHeight);
- }
- if (distractorData) {
- if (distractorData.keyDistractors || distractorData.keyHallucinations) {
- setKeyDistractors(distractorData.keyDistractors || distractorData.keyHallucinations);
- }
- if (distractorData.randomDistractorParams || distractorData.randomHallucinationParams) {
- setRandomDistractorParams(distractorData.randomDistractorParams || distractorData.randomHallucinationParams);
- }
- }
- setEntities(parsedEntities);
- const targetEntity = parsedEntities.find((entity) => entity.type === "target");
- if (targetEntity) {
- setTargetDirection(targetEntity.direction || 0);
- setDirectionInput(((targetEntity.direction || 0) * (180 / Math.PI)).toString());
- }
- if (hasDistractorData && mode === "regular") {
- setMode("distractor");
- } else if (!hasDistractorData && mode === "distractor") {
- setMode("regular");
- setKeyDistractors([]);
- setRandomDistractorParams(DEFAULT_RANDOM_DISTRACTOR_PARAMS);
- }
- setShouldAutoSimulate(true);
- } catch (err) {
- alert("Failed to parse file. Ensure it's a valid JSON format.");
+ if (setTrial_name) {
+ setTrial_name(trialNameFromLoadedFile(file));
+ }
+ const text = await file.text();
+ const parsedData = JSON.parse(text);
+ const {
+ entities: parsedEntities,
+ hasDistractorData,
+ distractorData,
+ simulationParams,
+ trialName
+ } = parseFileData(parsedData);
+ if (setTrial_name && trialName) {
+ setTrial_name(trialName);
+ }
+ if (simulationParams) {
+ if (simulationParams.videoLength !== undefined) setVideoLength(simulationParams.videoLength);
+ if (simulationParams.ballSpeed !== undefined) setBallSpeed(simulationParams.ballSpeed);
+ if (simulationParams.fps !== undefined) setFps(simulationParams.fps);
+ if (simulationParams.worldWidth !== undefined) setWorldWidth(simulationParams.worldWidth);
+ if (simulationParams.worldHeight !== undefined) setWorldHeight(simulationParams.worldHeight);
+ }
+ if (distractorData) {
+ if (distractorData.keyDistractors || distractorData.keyHallucinations) {
+ setKeyDistractors(distractorData.keyDistractors || distractorData.keyHallucinations);
}
- };
- reader.readAsText(file);
+ if (distractorData.randomDistractorParams || distractorData.randomHallucinationParams) {
+ setRandomDistractorParams(distractorData.randomDistractorParams || distractorData.randomHallucinationParams);
+ }
+ }
+ setEntities(parsedEntities);
+ const targetEntity = parsedEntities.find((entity) => entity.type === "target");
+ if (targetEntity) {
+ setTargetDirection(targetEntity.direction || 0);
+ setDirectionInput(((targetEntity.direction || 0) * (180 / Math.PI)).toString());
+ }
+ if (hasDistractorData && mode === "regular") {
+ setMode("distractor");
+ } else if (!hasDistractorData && mode === "distractor") {
+ setMode("regular");
+ setKeyDistractors([]);
+ setRandomDistractorParams(DEFAULT_RANDOM_DISTRACTOR_PARAMS);
+ }
+ setShouldAutoSimulate(true);
} catch (err) {
- console.error("Error clearing simulation:", err);
- alert("An unexpected error occurred while clearing the simulation.");
+ alert("Failed to parse file. Ensure it's a valid JSON format.");
}
}
};
@@ -227,7 +289,7 @@ export const createSaveDataHandler = ({
}
if (!saveDirectoryHandle) {
try {
- const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, null);
+ const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, null, trial_name);
downloadJSON(initStateData, `${trial_name}_init_state_entities.json`);
downloadJSON(simData, `${trial_name}_simulation_data.json`);
if (autoDownloadWebM && videoPlayerRef && videoPlayerRef.current) {
@@ -283,7 +345,7 @@ export const createSaveDataHandler = ({
}
const trialDirHandle = await saveDirectoryHandle.getDirectoryHandle(trial_name, { create: true });
const simulationParams = { videoLength, ballSpeed, fps, worldWidth, worldHeight };
- const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, simulationParams);
+ const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, simulationParams, trial_name);
const entitiesFileHandle = await trialDirHandle.getFileHandle('init_state_entities.json', { create: true });
const entitiesWritable = await entitiesFileHandle.createWritable();
await entitiesWritable.write(JSON.stringify(initStateData, null, 2));
@@ -308,7 +370,7 @@ export const createSaveDataHandler = ({
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
alert("Permission denied. Falling back to download files.");
const simulationParams = { videoLength, ballSpeed, fps, worldWidth, worldHeight };
- const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, simulationParams);
+ const initStateData = prepareInitStateData(entities, mode, keyDistractors, randomDistractorParams, simulationParams, trial_name);
downloadJSON(initStateData, `${trial_name}_init_state_entities.json`);
downloadJSON(simData, `${trial_name}_simulation_data.json`);
alert("Files downloaded successfully!");