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} +
+ +
+
+ + handleInputChange('x', e.target.value)} + onBlur={() => handleInputBlur('x')} + style={inputStyle} + /> +
+
+ + handleInputChange('y', e.target.value)} + onBlur={() => handleInputBlur('y')} + style={inputStyle} + /> +
+
+ + {selectedEntity.type !== ENTITY_TYPES.TARGET && ( +
+
+ + handleInputChange('width', e.target.value)} + onBlur={() => handleInputBlur('width')} + style={inputStyle} + /> +
+
+ + handleInputChange('height', e.target.value)} + onBlur={() => handleInputBlur('height')} + style={inputStyle} + /> +
+
+ )} + + {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!");