Files
artcraft/app/pages/PageEnigma/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx
T
2024-05-02 20:16:55 -04:00

443 lines
13 KiB
TypeScript

import { useContext, useEffect, useState } from "react";
import { Transition } from "@headlessui/react";
import {
faChevronDown,
faChevronUp,
faCube,
faTrash,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AppUiContext } from "~/contexts/AppUiContext";
import { EngineContext } from "~/contexts/EngineContext";
import { Button, H5, InputVector } from "~/components";
import { XYZ } from "../../datastructures/common";
import { ACTION_TYPES } from "~/reducers/appUiReducer/types";
import { QueueNames } from "../../Queue/QueueNames";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toTimelineActions } from "../../Queue/toTimelineActions";
import { QueueKeyframe } from "~/pages/PageEnigma/models";
import { editorState, EditorStates } from "~/pages/PageEnigma/store/engine";
// import { current } from "tailwindcss/colors";
// TODO this will be useful later to fix the bug on leading zeros
// const formatNumber = (input: string): number => {
// // Convert the input string to a number to remove leading zeros
// const num = parseFloat(input);
// // Convert the number back to a string with at least two decimal places
// const str = num.toFixed(2);
// return parseFloat(str);
// };
const defaultAxises = {
x: "0",
y: "0",
z: "0",
};
// interface Axises {
// x: number | string;
// y: number | string;
// z: number | string;
// }
// console.log(formatNumber("000123.4567")); // Outputs: "123.46"
// console.log(formatNumber("000123.4")); // Outputs: "123.40"
// console.log(formatNumber("000123")); // Outputs: "123.00"
// console.log(formatNumber("0000.00")); // Outputs: "0.00"
// console.log(formatNumber("0000.000001")); // Outputs: "0.00"
export const ControlPanelSceneObject = () => {
const editorEngine = useContext(EngineContext);
const [appUiState, dispatchAppUiState] = useContext(AppUiContext);
const [isCollapsed, setIsCollapsed] = useState(false);
// obj token to track what is selected
const [initializedObj, initializedObjSet] = useState("");
// local translation axises to allow for validation before handing them to the engine
const [localPosition, localPositionSet] = useState(defaultAxises);
const [localRotation, localRotationSet] = useState(defaultAxises);
const [localScale, localScaleSet] = useState(defaultAxises);
// used to update engine object
const [inputsUpdated, inputsUpdatedSet] = useState(false);
const [inputsFocused, inputsFocusedSet] = useState(false);
// clears leading and trailing zeros
const sanitizeNumericInput = (input: string): number => {
const regex = /(\-?\d+)(\.\d+)?/;
const matches = input.match(regex) || [];
const integerPart = matches[1];
const decimalPart = matches[2];
const decimal = decimalPart !== undefined ? parseFloat(decimalPart) : 0.0;
if (integerPart === undefined) {
return decimal;
}
const integer = parseInt(integerPart);
if (decimalPart === undefined) {
return integer;
}
if (Number(input) > 0) {
return integer + decimal;
} else {
return integer - decimal;
}
};
// runs sanitizeNumericInput on each axis, creating a new object of sanitized values
const sanitize = (xyz: { [i: string]: string }) => {
return Object.keys(xyz).reduce((obj, currentKey) => {
return {
...obj,
[currentKey]: sanitizeNumericInput(xyz[currentKey].toString()),
};
}, {});
};
// checks if *ANY* number within a translation object is NaN and therefore will not be sent to the engine
const isValid = (xyz: { [i: string]: string }) =>
!Object.values(xyz).some((n) => {
return isNaN(n);
});
const positionSanitized = sanitize(localPosition);
const rotationSanitized = sanitize(localRotation);
const scaleSanitized = sanitize(localScale);
const positionValid = isValid(positionSanitized);
const rotationValid = isValid(rotationSanitized);
const scaleValid = isValid(scaleSanitized);
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
const position =
appUiState.controlPanel.currentSceneObject?.objectVectors?.position;
const rotation =
appUiState.controlPanel.currentSceneObject?.objectVectors?.rotation;
const scale =
appUiState.controlPanel.currentSceneObject?.objectVectors?.scale;
const currentSceneObject = appUiState.controlPanel.currentSceneObject;
useEffect(() => {
const isCurrentObj =
appUiState.controlPanel.currentSceneObject.object_uuid === initializedObj;
// TODO this causes a subtle bug because it renders way too many times.
if (!appUiState.controlPanel.currentSceneObject) {
return;
}
const vectors = appUiState.controlPanel.currentSceneObject.objectVectors;
const numsToStrings = (inputObj: { [i: string]: number }) =>
Object.keys(inputObj).reduce(
(obj, currentKey) => ({
...obj,
[currentKey]: Number(inputObj[currentKey]).toString(),
}),
defaultAxises,
);
const objMatch = (
inputObj: { [i: string]: number },
refObj: { [i: string]: number },
) =>
Object.keys(inputObj).some(
(currentKey: string) => inputObj[currentKey] !== refObj[currentKey],
);
const positionMismatch = objMatch(vectors.position, positionSanitized);
const rotationMismatch = objMatch(vectors.rotation, rotationSanitized);
const scaleMismatch = objMatch(vectors.scale, scaleSanitized);
if (
appUiState.controlPanel.isShowing &&
// if the current object isn't the cached object: update local
(!isCurrentObj ||
// if the inputs are not in focus, and local state does not match the selected engine object values: update local
(!inputsFocused &&
(positionMismatch || rotationMismatch || scaleMismatch)))
) {
// update cached object
initializedObjSet(appUiState.controlPanel.currentSceneObject.object_uuid);
// local state relies on strings
localPositionSet(numsToStrings(vectors.position));
localRotationSet(numsToStrings(vectors.rotation));
localScaleSet(numsToStrings(vectors.scale));
} else if (!appUiState.controlPanel.isShowing && isCurrentObj) {
initializedObjSet("");
localPositionSet(defaultAxises);
localRotationSet(defaultAxises);
localScaleSet(defaultAxises);
}
// updating engine if values originate from the inputs and are valid
if (inputsUpdated && positionValid && rotationValid && scaleValid) {
inputsUpdatedSet(false);
editorEngine?.setSelectedObject(
positionSanitized,
rotationSanitized,
scaleSanitized,
);
}
}, [
appUiState.controlPanel,
editorEngine,
initializedObj,
inputsFocused,
inputsUpdated,
positionSanitized,
positionValid,
rotationSanitized,
rotationValid,
scaleSanitized,
scaleValid,
]);
if (
!appUiState.controlPanel.currentSceneObject ||
editorState.value === EditorStates.PREVIEW
) {
return null;
}
const handlePositionChange = (xyz: XYZ) => {
localPositionSet(xyz);
inputsUpdatedSet(true);
// onChange functions no longer update the engine directly. Commented out for future reference.
// if (!currentSceneObject) {
// console.log("Missing Scene Object Position");
// return;
// }
// dispatchAppUiState({
// type: ACTION_TYPES.UPDATE_CONTROLPANELS_SCENEOBJECT,
// payload: {
// group: currentSceneObject.group,
// object_uuid: currentSceneObject.object_uuid,
// object_name: currentSceneObject.object_name,
// version: currentSceneObject.version,
// objectVectors: {
// ...appUiState.controlPanel.currentSceneObject.objectVectors,
// position: {
// ...stringsToNums(xyz),
// },
// },
// },
// });
};
const handleRotationChange = (xyz: XYZ) => {
localRotationSet(xyz);
inputsUpdatedSet(true);
// if (appUiState) {
// if (currentSceneObject == null) {
// console.log("Missing Scene Object Rotation");
// return;
// }
// dispatchAppUiState({
// type: ACTION_TYPES.UPDATE_CONTROLPANELS_SCENEOBJECT,
// payload: {
// group: currentSceneObject.group,
// object_uuid: currentSceneObject.object_uuid,
// object_name: currentSceneObject.object_name,
// version: currentSceneObject.version,
// objectVectors: {
// ...appUiState.controlPanel.currentSceneObject.objectVectors,
// rotation: {
// ...stringsToNums(xyz),
// },
// },
// },
// });
// }
};
const handleScaleChange = (xyz: XYZ) => {
localScaleSet(xyz);
inputsUpdatedSet(true);
// if (appUiState) {
// if (currentSceneObject == null) {
// console.log("Missing Scene Object Scale");
// return;
// }
// dispatchAppUiState({
// type: ACTION_TYPES.UPDATE_CONTROLPANELS_SCENEOBJECT,
// payload: {
// group: currentSceneObject.group,
// object_uuid: currentSceneObject.object_uuid,
// object_name: currentSceneObject.object_name,
// version: currentSceneObject.version,
// objectVectors: {
// ...appUiState.controlPanel.currentSceneObject.objectVectors,
// scale: {
// ...stringsToNums(xyz),
// },
// },
// },
// });
// }
};
const onFocus = () => {
inputsFocusedSet(true);
};
const onPositionBlur = () => {
inputsFocusedSet(false);
if (positionValid) {
localPositionSet(positionSanitized);
}
};
const onRotationBlur = () => {
inputsFocusedSet(false);
if (rotationValid) {
localRotationSet(rotationSanitized);
}
};
const onScaleBlur = () => {
inputsFocusedSet(false);
if (scaleValid) {
localScaleSet(scaleSanitized);
}
};
const handleOnAddKeyFrame = () => {
if (appUiState) {
if (
position == null ||
rotation == null ||
scale == null ||
currentSceneObject == null
) {
return;
}
for (const key in editorEngine?.timeline.characters) {
const element = editorEngine?.timeline.characters[key];
if (key == currentSceneObject.object_uuid) {
currentSceneObject.group = element;
break;
}
}
Queue.publish({
queueName: QueueNames.TO_TIMELINE,
action: toTimelineActions.ADD_KEYFRAME,
data: {
group: currentSceneObject.group,
object_uuid: currentSceneObject.object_uuid,
object_name: currentSceneObject.object_name,
version: 1,
position: currentSceneObject.objectVectors.position,
rotation: currentSceneObject.objectVectors.rotation,
scale: currentSceneObject.objectVectors.scale,
} as QueueKeyframe,
});
}
};
const handleDeleteObject = () => {
editorEngine?.deleteObject(currentSceneObject.object_uuid);
};
return (
<Transition
show={appUiState?.controlPanel.isShowing}
className={[
"absolute bottom-0 right-0",
"m-4 flex h-fit w-56 flex-col gap-2",
"rounded-lg",
"border border-ui-panel-border",
"bg-ui-panel p-3.5 text-white",
].join(" ")}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="mb-1 flex justify-between">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCube} />
<p className="max-w-36 truncate font-semibold">
{appUiState.controlPanel.currentSceneObject.object_name}
</p>
</div>
<FontAwesomeIcon
icon={isCollapsed ? faChevronUp : faChevronDown}
onClick={toggleCollapse}
className="cursor-pointer opacity-75 transition-opacity duration-100 ease-in-out hover:opacity-50"
/>
</div>
<Transition
show={!isCollapsed}
enter="transition-all duration-200 ease-in-out"
enterFrom="opacity-0 max-h-0"
enterTo="opacity-100 max-h-96"
leave="transition-all duration-200 ease-in-out"
leaveFrom="opacity-100 max-h-96"
leaveTo="opacity-0 max-h-0"
className={"flex flex-col gap-2 overflow-y-auto"}>
<div className="flex flex-col gap-1">
<H5>Location</H5>
<InputVector
{...{ ...localPosition, onBlur: onPositionBlur, onFocus }}
onChange={handlePositionChange}
/>
</div>
<div className="flex flex-col gap-1">
<H5>Rotation</H5>
<InputVector
{...{ ...localRotation, onBlur: onRotationBlur, onFocus }}
onChange={handleRotationChange}
increment={1}
/>
</div>
<div className="flex flex-col gap-1">
<H5>Scale</H5>
<InputVector
{...{ ...localScale, onBlur: onScaleBlur, onFocus }}
onChange={handleScaleChange}
/>
</div>
</Transition>
<div className="mt-2 flex gap-1.5">
<Button
variant="secondary"
className="grow"
onClick={handleOnAddKeyFrame}>
Add Keyframe (K)
</Button>
<Button
variant="secondary"
icon={faTrash}
onClick={handleDeleteObject}
/>
</div>
</Transition>
);
};