diff --git a/app/src/components/icons/ExportCommonIcons.tsx b/app/src/components/icons/ExportCommonIcons.tsx index eaf1cf4..a8fe70b 100644 --- a/app/src/components/icons/ExportCommonIcons.tsx +++ b/app/src/components/icons/ExportCommonIcons.tsx @@ -1079,6 +1079,35 @@ export const SaveIcon = () => { ); }; +export const FolderIcon = ({ isOpen }: { isOpen: boolean }) => ( + + {isOpen ? ( + + ) : ( + + )} + +); + +export const CubeIcon = () => ( + + + +); + +export const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => { + return isOpen ? ( + + + + ) : ( + + + + ); +}; + + export const SaveVersionIcon = () => { return ( {!selectedUser && ( <> + {loadingProgress > 0 && } {!isPlaying && ( diff --git a/app/src/modules/builder/asset/assetsGroup.tsx b/app/src/modules/builder/asset/assetsGroup.tsx index da1a57d..d46f2f3 100644 --- a/app/src/modules/builder/asset/assetsGroup.tsx +++ b/app/src/modules/builder/asset/assetsGroup.tsx @@ -95,7 +95,7 @@ function AssetsGroup({ plane }: { readonly plane: React.MutableRefObject() }: { baseName?: string; existingGroups: AssetGroup[]; usedNames?: Set }): string => { + // Extract the base name without any existing numbers + const baseMatch = baseName.match(/^(.*?)(?:\.(\d+))?$/); + const trueBaseName = baseMatch![1]; + const existingNumber = baseMatch![2] ? parseInt(baseMatch![2], 10) : 0; + + // Find all groups that match the true base name pattern (both existing and newly used) + const allUsedNumbers: number[] = []; + const pattern = new RegExp(`^${trueBaseName}(?:\\.(\\d+))?$`); + + // Check existing groups + existingGroups.forEach((group) => { + const match = group.groupName.match(pattern); + if (match) { + if (!match[1]) { + allUsedNumbers.push(0); + } else { + allUsedNumbers.push(parseInt(match[1], 10)); + } + } + }); + + // Check names we've already assigned in this operation + usedNames.forEach((usedName) => { + const match = usedName.match(pattern); + if (match) { + if (!match[1]) { + allUsedNumbers.push(0); + } else { + allUsedNumbers.push(parseInt(match[1], 10)); + } + } + }); + + // If no existing groups match the pattern and no used names match, return base name without number + if (allUsedNumbers.length === 0) { + return trueBaseName; + } + + // Also include the existing number from the copied group + if (!allUsedNumbers.includes(existingNumber)) { + allUsedNumbers.push(existingNumber); + } + + const maxNumber = Math.max(...allUsedNumbers); + let nextNumber = maxNumber + 1; + + // Find the next available number that hasn't been used + while (allUsedNumbers.includes(nextNumber)) { + nextNumber++; + } + + // If the only used number is 0 (meaning only the base name exists without numbers) + // and we're generating the first duplicate, use .001 instead of no number + if (allUsedNumbers.length === 1 && allUsedNumbers[0] === 0 && nextNumber === 1) { + return `${trueBaseName}.001`; + } + + // For the very first group, return base name without number + if (allUsedNumbers.length === 0 || (allUsedNumbers.length === 1 && allUsedNumbers[0] === 0 && existingNumber === 0)) { + return trueBaseName; + } + + // Handle numbering for duplicates + const digitCount = nextNumber.toString().length; + const padding = Math.max(3, digitCount); + return `${trueBaseName}.${nextNumber.toString().padStart(padding, "0")}`; +}; + +export default generateUniqueAssetGroupName; diff --git a/app/src/modules/builder/builder.tsx b/app/src/modules/builder/builder.tsx index 4034fcd..5e9c941 100644 --- a/app/src/modules/builder/builder.tsx +++ b/app/src/modules/builder/builder.tsx @@ -2,11 +2,14 @@ import * as THREE from "three"; import { useEffect, useRef } from "react"; -import { RootState, useFrame, useThree } from "@react-three/fiber"; +import { useParams } from "react-router-dom"; import { Geometry } from "@react-three/csg"; +import { useFrame, useThree } from "@react-three/fiber"; ////////// Zustand State Imports ////////// +import { useSceneContext } from "../scene/sceneContext"; +import { useBuilderStore } from "../../store/builder/useBuilderStore"; import { useToggleView, useWallVisibility, useRoofVisibility, useShadows, useToolMode, useRenderDistance, useLimitDistance } from "../../store/builder/store"; ////////// 3D Function Imports ////////// @@ -24,12 +27,10 @@ import FloorGroup from "./floor/floorGroup"; import ZoneGroup from "./zone/zoneGroup"; import Decal from "./Decal/decal"; -import { useParams } from "react-router-dom"; -import { useBuilderStore } from "../../store/builder/useBuilderStore"; import { findEnvironment } from "../../services/factoryBuilder/environment/findEnvironment"; export default function Builder() { - const state = useThree(); + const { gl, scene } = useThree(); const plane = useRef(null); const csgRef = useRef(null); @@ -41,21 +42,27 @@ export default function Builder() { const { setRenderDistance } = useRenderDistance(); const { setLimitDistance } = useLimitDistance(); const { projectId } = useParams(); + const { scene: storeScene } = useSceneContext(); const { setHoveredPoint, setHoveredLine } = useBuilderStore(); + useEffect(() => { + storeScene.current = scene; + }, [scene]); + useEffect(() => { if (!toggleView) { setHoveredLine(null); setHoveredPoint(null); - state.gl.domElement.style.cursor = "default"; + gl.domElement.style.cursor = "default"; setToolMode("cursor"); } - }, [toggleView]); + }, [toggleView, gl]); useEffect(() => { if (!projectId) return; findEnvironment(projectId).then((data) => { + if (!data) return; setRoofVisibility(data.roofVisibility); setWallVisibility(data.wallVisibility); setShadows(data.shadowVisibility); diff --git a/app/src/modules/builder/testUi/outline.css b/app/src/modules/builder/testUi/outline.css new file mode 100644 index 0000000..765ca66 --- /dev/null +++ b/app/src/modules/builder/testUi/outline.css @@ -0,0 +1,422 @@ +/* Hierarchy Overlay Styles */ + +.outline-overlay { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif; +} + +.outline-card { + width: 320px; + background: rgba(15, 15, 25, 0.98); + backdrop-filter: blur(30px); + border-radius: 16px; + border: 1px solid hsl(262 83% 58% / 0.3); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 60px hsl(262 83% 68% / 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 0 1px hsl(262 83% 58% / 0.1); + overflow: hidden; + animation: slideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + position: relative; +} + +.outline-card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, hsl(262 83% 68% / 0.05), transparent 50%); + pointer-events: none; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +/* Header */ +.outline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: linear-gradient(135deg, hsl(262 83% 58% / 0.15), hsl(262 83% 58% / 0.05)); + border-bottom: 1px solid hsl(262 83% 58% / 0.3); + position: relative; +} + +.outline-header::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, hsl(262 83% 68% / 0.5) 50%, transparent); +} + +.header-title { + display: flex; + align-items: center; + gap: 10px; + color: #e0e0ff; +} + +.header-title svg { + color: hsl(262 83% 68%); + filter: drop-shadow(0 0 4px hsl(262 83% 68% / 0.5)); +} + +.header-title h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.close-button { + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.close-button:hover { + background: hsl(262 83% 58% / 0.2); + color: #e0e0ff; + transform: rotate(90deg); + box-shadow: 0 0 8px hsl(262 83% 68% / 0.3); +} + +/* Toolbar */ +.outline-toolbar { + display: flex; + gap: 4px; + padding: 12px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid hsl(262 83% 58% / 0.1); +} + +.toolbar-button { + background: hsl(262 83% 58% / 0.1); + border: 1px solid hsl(262 83% 58% / 0.2); + color: hsl(262 83% 68%); + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.toolbar-button:hover { + background: hsl(262 83% 58% / 0.2); + border-color: hsl(262 83% 58% / 0.4); + transform: translateY(-2px); + box-shadow: 0 4px 12px hsl(262 83% 58% / 0.3), 0 0 16px hsl(262 83% 68% / 0.2); +} + +.toolbar-button:active { + transform: translateY(0); +} + +/* Content */ +.outline-content { + max-height: 500px; + overflow-y: auto; + padding: 8px 0; + background: rgba(0, 0, 0, 0.2); +} + +.outline-content::-webkit-scrollbar { + width: 6px; +} + +.outline-content::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +.outline-content::-webkit-scrollbar-thumb { + background: hsl(262 83% 58% / 0.4); + border-radius: 3px; + box-shadow: 0 0 4px hsl(262 83% 68% / 0.2); +} + +.outline-content::-webkit-scrollbar-thumb:hover { + background: hsl(262 83% 58% / 0.6); + box-shadow: 0 0 8px hsl(262 83% 68% / 0.4); +} + +/* Tree Node */ +.tree-node { + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.tree-node.drop-target-highlight { + background: hsl(262 83% 58% / 0.12); + border-radius: 10px; + box-shadow: inset 0 0 0 2px hsl(262 83% 58% / 0.4), 0 0 20px hsl(262 83% 68% / 0.3), 0 4px 12px hsl(262 83% 58% / 0.2); +} + +.tree-node-content { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + margin: 0px 8px; + border-radius: 8px; + cursor: grab; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + background: transparent; +} + +.tree-node-content:hover { + background: hsl(262 83% 58% / 0.15); +} + +.tree-node-content.selected { + background: hsl(262 83% 58% / 0.3); +} + +.tree-node-content.dragging { + opacity: 0.5; + background: hsl(262 83% 58% / 0.25); + cursor: grabbing; + transform: scale(0.98); + box-shadow: 0 4px 12px hsl(262 83% 58% / 0.3); +} + +.tree-node-content.locked { + opacity: 0.6; + cursor: not-allowed; +} + +.tree-node-content.hidden { + opacity: 0.4; +} + +.expand-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: #9ca3af; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + width: 20px; + height: 20px; + border-radius: 4px; +} + +.expand-button:hover { + background: hsl(262 83% 58% / 0.2); + color: hsl(262 83% 68%); + box-shadow: 0 0 8px hsl(262 83% 68% / 0.2); +} + +.node-icon { + display: flex; + align-items: center; + justify-content: center; + color: hsl(262 83% 68%); + flex-shrink: 0; +} + +.node-name { + flex: 1; + color: #e5e7eb; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} + +.node-controls { + display: flex; +} + +.control-button { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.control-button:hover { + background: hsl(262 83% 58% / 0.2); + color: hsl(262 83% 68%); + box-shadow: 0 0 6px hsl(262 83% 68% / 0.3); +} + +.tree-children { + position: relative; +} + +.tree-children::before { + content: ""; + position: absolute; + left: 24px; + top: 0; + bottom: 0; + width: 1px; + background: linear-gradient(to bottom, hsl(262 83% 58% / 0.3), hsl(262 83% 58% / 0.1)); + box-shadow: 0 0 4px hsl(262 83% 68% / 0.2); +} + +/* Footer */ +.outline-footer { + padding: 12px 16px; + background: rgba(0, 0, 0, 0.3); + border-top: 1px solid hsl(262 83% 58% / 0.1); +} + +.footer-stats { + color: #9ca3af; + font-size: 12px; + font-weight: 500; +} + +/* Toggle Button */ +.outline-toggle { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + background: linear-gradient(135deg, hsl(262 83% 58% / 0.95), hsl(262 83% 58% / 0.85)); + backdrop-filter: blur(12px); + border: 1px solid hsl(262 83% 58% / 0.4); + border-radius: 12px; + padding: 12px; + cursor: pointer; + color: white; + box-shadow: 0 10px 30px hsl(262 83% 58% / 0.4), 0 0 25px hsl(262 83% 68% / 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.outline-toggle:hover { + transform: scale(1.08) rotate(2deg); + box-shadow: 0 15px 45px hsl(262 83% 58% / 0.5), 0 0 40px hsl(262 83% 68% / 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.outline-toggle:active { + transform: scale(0.95); +} + +/* Enhanced Glow Effect */ +@keyframes cardGlow { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } +} + +.outline-card:hover { + border-color: hsl(262 83% 58% / 0.5); + box-shadow: 0 25px 70px rgba(0, 0, 0, 0.7), 0 0 80px hsl(262 83% 68% / 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 0 0 1px hsl(262 83% 58% / 0.2); +} + +/* Responsive */ +@media (max-width: 768px) { + .outline-overlay { + top: 10px; + right: 10px; + } + + .outline-card { + width: 280px; + } + + .outline-content { + max-height: 400px; + } +} + +/* Root Drop Target Highlight */ +.outline-content.root-drop-target { + background: hsl(262 83% 58% / 0.12); + box-shadow: inset 0 0 0 2px hsl(262 83% 58% / 0.4), inset 0 0 20px hsl(262 83% 68% / 0.2); + border-radius: 8px; +} + +.outline-content.root-drop-target::before { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: hsl(262 83% 68%); + font-size: 14px; + font-weight: 600; + pointer-events: none; + text-shadow: 0 0 8px hsl(262 83% 68% / 0.5); + z-index: 10; + background: rgba(15, 15, 25, 0.8); + padding: 8px 16px; + border-radius: 8px; + border: 1px solid hsl(262 83% 58% / 0.4); +} + +/* Multi-selection styling */ +.tree-node-content.multi-selected { + background: hsl(262 83% 58% / 0.25); +} + +.tree-node-content.multi-selected::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(to bottom, hsl(262 83% 68% / 0.8), hsl(262 83% 68% / 0.4)); + box-shadow: 0 0 8px hsl(262 83% 68% / 0.4); +} + +.tree-node-content.multi-selected:hover { + background: hsl(262 83% 58% / 0.35); +} + +/* Selection count indicator (optional - add to footer) */ +.footer-stats.multi-selection { + color: hsl(262 83% 68%); + font-weight: 600; +} diff --git a/app/src/modules/builder/testUi/outline.tsx b/app/src/modules/builder/testUi/outline.tsx new file mode 100644 index 0000000..eb8b0fc --- /dev/null +++ b/app/src/modules/builder/testUi/outline.tsx @@ -0,0 +1,697 @@ +import { useState, useRef, DragEvent, useCallback } from "react"; +import { useParams } from "react-router-dom"; +import { EyeIcon, LockIcon, FolderIcon, ChevronIcon, CubeIcon, AddIcon, DeleteIcon, KebebIcon } from "../../../components/icons/ExportCommonIcons"; +import RenameInput from "../../../components/ui/inputs/RenameInput"; +import { useSceneContext } from "../../scene/sceneContext"; +import { useSocketStore } from "../../../store/socket/useSocketStore"; +import useAssetResponseHandler from "../../collaboration/responseHandler/useAssetResponseHandler"; +import "./outline.css"; + +import { getUserData } from "../../../functions/getUserData"; +import { setAssetsApi } from "../../../services/factoryBuilder/asset/floorAsset/setAssetsApi"; + +interface DragState { + draggedItem: AssetGroupChild | null; + draggedItemParentGroupUuid: string | null; + targetGroupUuid: string | null; + isRootTarget: boolean; +} + +// Tree Node Component +const TreeNode = ({ + item, + level = 0, + dragState, + onDragStart, + onDragOver, + onDragLeave, + onClick, + onDrop, + onToggleExpand, + onOptionClick, +}: { + item: AssetGroupChild; + level?: number; + dragState: DragState; + onDragStart: (e: DragEvent, item: AssetGroupChild, parentGroupUuid: string | null) => void; + onDragOver: (e: DragEvent, item: AssetGroupChild) => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent, targetItem: AssetGroupChild) => void; + onClick: (e: React.MouseEvent, selectedItem: AssetGroupChild) => void; + onToggleExpand: (groupUuid: string, newExpanded: boolean) => void; + onOptionClick: (option: string, item: AssetGroupChild) => void; +}) => { + const { assetGroupStore, assetStore } = useSceneContext(); + const { hasSelectedAsset, selectedAssets } = assetStore(); + const { isGroup, getGroupsContainingAsset, getGroupsContainingGroup } = assetGroupStore(); + const isGroupNode = isGroup(item); + + const itemName = isGroupNode ? item.groupName : item.modelName; + const isVisible = item.isVisible; + const isLocked = item.isLocked; + const isExpanded = isGroupNode ? item.isExpanded : false; + const isSelected = !isGroupNode ? hasSelectedAsset(item.modelUuid) : false; + const isMultiSelected = !isGroupNode && selectedAssets.length > 1 && hasSelectedAsset(item.modelUuid); + + // Determine the parent group of this item + const getParentGroup = useCallback( + (currentItem: AssetGroupChild): string | null => { + if (isGroup(currentItem)) { + const parents = getGroupsContainingGroup(currentItem.groupUuid); + return parents.length > 0 ? parents[0].groupUuid : null; + } else { + const parents = getGroupsContainingAsset(currentItem.modelUuid); + return parents.length > 0 ? parents[0].groupUuid : null; + } + }, + [getGroupsContainingAsset, getGroupsContainingGroup, isGroup] + ); + + // Check if this node should be highlighted as a drop target + const isDropTarget = useCallback(() => { + if (!dragState.draggedItem || !dragState.targetGroupUuid || !isGroupNode) return false; + + // Get the group UUID this item belongs to or is + const thisGroupUuid = item.groupUuid; + + // Highlight if this is the target group or belongs to the target group + return thisGroupUuid === dragState.targetGroupUuid; + }, [dragState, isGroupNode, item, getParentGroup]); + + const handleNodeDragStart = (e: DragEvent) => { + const parentGroupUuid = getParentGroup(item); + onDragStart(e, item, parentGroupUuid); + }; + + const handleNodeDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDragOver(e, item); + }; + + const handleNodeDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDrop(e, item); + }; + + const handleNodeClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick(e, item); + }; + + const handleOptionClick = (e: React.MouseEvent, option: string) => { + e.preventDefault(); + e.stopPropagation(); + onOptionClick(option, item); + }; + + const shouldShowHighlight = isDropTarget(); + + return ( + + + {isGroupNode && ( + onToggleExpand(item.groupUuid, !isExpanded)}> + + + )} + + {isGroupNode ? : } + + {}} canEdit={true} /> + + + { + handleOptionClick(e, "visibility"); + }} + > + + + { + handleOptionClick(e, "lock"); + }} + > + + + {isGroupNode && ( + { + handleOptionClick(e, "kebab"); + }} + > + + + )} + + + + {isGroupNode && isExpanded && item.children && ( + + {item.children.map((child) => ( + + ))} + + )} + + ); +}; + +// Main Component +export const Outline = () => { + const [isOpen, setIsOpen] = useState(true); + const lastSelectedRef = useRef<{ item: AssetGroupChild; index: number } | null>(null); + const dragStateRef = useRef({ + draggedItem: null, + draggedItemParentGroupUuid: null, + targetGroupUuid: null, + isRootTarget: false, + }); + const [_, forceUpdate] = useState({}); + const { scene, assetGroupStore, assetStore, versionStore, undoRedo3DStore } = useSceneContext(); + const { addSelectedAsset, clearSelectedAssets, getAssetById, peekToggleVisibility, toggleSelectedAsset, selectedAssets } = assetStore(); + const { groupHierarchy, isGroup, getGroupsContainingAsset, getFlatGroupChildren, setGroupExpanded, addChildToGroup, removeChildFromGroup, getGroupsContainingGroup } = assetGroupStore(); + const { projectId } = useParams(); + const { push3D } = undoRedo3DStore(); + const { builderSocket } = useSocketStore(); + const { userId, organization } = getUserData(); + const { selectedVersion } = versionStore(); + const { updateAssetInScene } = useAssetResponseHandler(); + + const getFlattenedHierarchy = useCallback((): AssetGroupChild[] => { + const flattened: AssetGroupChild[] = []; + + const traverse = (items: AssetGroupChild[]) => { + items.forEach((item) => { + flattened.push(item); + if (isGroup(item) && item.isExpanded && item.children) { + traverse(item.children); + } + }); + }; + + traverse(groupHierarchy); + return flattened; + }, [groupHierarchy, isGroup]); + + const handleAssetVisibilityUpdate = async (asset: Asset | null) => { + if (!asset) return; + + if (!builderSocket?.connected) { + setAssetsApi({ + modelUuid: asset.modelUuid, + modelName: asset.modelName, + assetId: asset.assetId, + position: asset.position, + rotation: asset.rotation, + scale: asset.scale, + isCollidable: asset.isCollidable, + opacity: asset.opacity, + isLocked: asset.isLocked, + isVisible: asset.isVisible, + versionId: selectedVersion?.versionId || "", + projectId: projectId || "", + }) + .then((data) => { + if (!data.message || !data.data) { + echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`); + return; + } + if (data.message === "Model updated successfully" && data.data) { + const model: Asset = { + modelUuid: data.data.modelUuid, + modelName: data.data.modelName, + assetId: data.data.assetId, + position: data.data.position, + rotation: data.data.rotation, + scale: data.data.scale, + isLocked: data.data.isLocked, + isVisible: data.data.isVisible, + isCollidable: data.data.isCollidable, + opacity: data.data.opacity, + ...(data.data.eventData ? { eventData: data.data.eventData } : {}), + }; + + updateAssetInScene(model, () => { + echo.info(`${asset.isVisible ? "Hid" : "Unhid"} asset: ${model.modelName}`); + }); + } else { + echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`); + } + }) + .catch(() => { + echo.error(`Error ${asset.isVisible ? "hiding" : "unhiding"} asset: ${asset.modelName}`); + }); + } else { + const data = { + organization, + modelUuid: asset.modelUuid, + modelName: asset.modelName, + assetId: asset.assetId, + position: asset.position, + rotation: asset.rotation, + scale: asset.scale, + isCollidable: asset.isCollidable, + opacity: asset.opacity, + isLocked: asset.isLocked, + isVisible: asset.isVisible, + socketId: builderSocket?.id, + versionId: selectedVersion?.versionId || "", + projectId, + userId, + }; + + builderSocket.emit("v1:model-asset:add", data); + } + }; + + const handleToggleExpand = useCallback( + (groupUuid: string, newExpanded: boolean) => { + setGroupExpanded(groupUuid, newExpanded); + }, + [setGroupExpanded] + ); + + const handleDragStart = useCallback( + (e: DragEvent, item: AssetGroupChild, parentGroupUuid: string | null) => { + dragStateRef.current.draggedItem = item; + dragStateRef.current.draggedItemParentGroupUuid = parentGroupUuid; + + e.dataTransfer.effectAllowed = "move"; + forceUpdate({}); + }, + [isGroup] + ); + + const handleDragOver = useCallback( + (e: DragEvent, targetItem: AssetGroupChild) => { + e.preventDefault(); + + const draggedItem = dragStateRef.current.draggedItem; + if (!draggedItem) return; + + // Determine target group + let targetGroupUuid: string | null = null; + let isTargetAtRoot = false; + + if (isGroup(targetItem)) { + targetGroupUuid = targetItem.groupUuid; + // Check if this group is at root level + const parentGroups = getGroupsContainingGroup(targetItem.groupUuid); + isTargetAtRoot = parentGroups.length === 0; + } else { + // Get the group containing this asset + const groups = getGroupsContainingAsset(targetItem.modelUuid); + if (groups.length === 0) { + // Asset is at root level + isTargetAtRoot = true; + targetGroupUuid = null; + } else { + targetGroupUuid = groups[0].groupUuid; + isTargetAtRoot = false; + } + } + + // Get the dragged item's parent group + const draggedItemParentGroupUuid = dragStateRef.current.draggedItemParentGroupUuid; + + // If dragging over a root item and the dragged item is NOT at root, highlight root + if (isTargetAtRoot && draggedItemParentGroupUuid !== null && !isGroup(targetItem)) { + dragStateRef.current.targetGroupUuid = null; + dragStateRef.current.isRootTarget = true; + forceUpdate({}); + return; + } + + // If the target is the same as the dragged item's parent group, don't highlight + if (targetGroupUuid === draggedItemParentGroupUuid) { + dragStateRef.current.targetGroupUuid = null; + dragStateRef.current.isRootTarget = false; + forceUpdate({}); + return; + } + + // If dragging a group, check if we're trying to drop it into itself or its children + if (isGroup(draggedItem)) { + const draggedGroupChildren = getFlatGroupChildren(draggedItem.groupUuid); + if (targetGroupUuid && draggedGroupChildren.includes(targetGroupUuid)) { + dragStateRef.current.targetGroupUuid = null; + dragStateRef.current.isRootTarget = false; + forceUpdate({}); + return; + } + + // Don't allow dropping a group into itself + if (targetGroupUuid === draggedItem.groupUuid) { + dragStateRef.current.targetGroupUuid = null; + dragStateRef.current.isRootTarget = false; + forceUpdate({}); + return; + } + } + + // Update target group + if (dragStateRef.current.targetGroupUuid !== targetGroupUuid || dragStateRef.current.isRootTarget !== false) { + dragStateRef.current.targetGroupUuid = targetGroupUuid; + dragStateRef.current.isRootTarget = false; + forceUpdate({}); + } + }, + [isGroup, getGroupsContainingAsset, getGroupsContainingGroup, getFlatGroupChildren] + ); + + const handleRootDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + + const draggedItem = dragStateRef.current.draggedItem; + if (!draggedItem) return; + + const draggedItemParentGroupUuid = dragStateRef.current.draggedItemParentGroupUuid; + + // Only highlight root if item is currently in a group + if (draggedItemParentGroupUuid !== null) { + dragStateRef.current.targetGroupUuid = null; + dragStateRef.current.isRootTarget = true; + forceUpdate({}); + } + }, []); + + const handleDragEnd = useCallback(() => { + dragStateRef.current = { + draggedItem: null, + draggedItemParentGroupUuid: null, + targetGroupUuid: null, + isRootTarget: false, + }; + + forceUpdate({}); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + + const draggedItem = dragStateRef.current.draggedItem; + if (!draggedItem) return; + + const targetGroupUuid = dragStateRef.current.targetGroupUuid; + const isRootTarget = dragStateRef.current.isRootTarget; + const draggedItemParentGroupUuid = dragStateRef.current.draggedItemParentGroupUuid; + + // Handle drop to root + if (isRootTarget && draggedItemParentGroupUuid !== null) { + console.log("Dropped to root:", draggedItem); + + // Remove from parent group + if (isGroup(draggedItem)) { + removeChildFromGroup(draggedItemParentGroupUuid, draggedItem.groupUuid); + } else { + removeChildFromGroup(draggedItemParentGroupUuid, draggedItem.modelUuid); + } + } + // Handle drop to a group + else if (targetGroupUuid && targetGroupUuid !== draggedItemParentGroupUuid) { + console.log("Dropped:", draggedItem, "into group:", targetGroupUuid); + + if (isGroup(draggedItem)) { + addChildToGroup(targetGroupUuid, { type: "Group", childrenUuid: draggedItem.groupUuid }); + } else { + addChildToGroup(targetGroupUuid, { type: "Asset", childrenUuid: draggedItem.modelUuid }); + } + } + + dragStateRef.current = { + draggedItem: null, + draggedItemParentGroupUuid: null, + targetGroupUuid: null, + isRootTarget: false, + }; + + forceUpdate({}); + }, + [isGroup, addChildToGroup, removeChildFromGroup] + ); + + const handleClick = useCallback( + (e: React.MouseEvent, item: AssetGroupChild) => { + e.preventDefault(); + const isShiftClick = e.shiftKey; + const isCtrlClick = e.ctrlKey; + + if (!scene.current) return; + + // Helper to get item ID + const getItemId = (i: AssetGroupChild): string => { + return isGroup(i) ? i.groupUuid : i.modelUuid; + }; + + // Single click (no modifiers) - select only this item + if (!isShiftClick && !isCtrlClick) { + if (!isGroup(item)) { + const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); + if (asset) { + clearSelectedAssets(); + addSelectedAsset(asset); + + // Update last selected reference + const flattened = getFlattenedHierarchy(); + const index = flattened.findIndex((flatItem) => getItemId(flatItem) === getItemId(item)); + lastSelectedRef.current = { item, index }; + } + } + } + // Ctrl+Click - toggle selection + else if (isCtrlClick && !isShiftClick) { + if (!isGroup(item)) { + const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); + if (asset) { + toggleSelectedAsset(asset); + + // Update last selected reference + const flattened = getFlattenedHierarchy(); + const index = flattened.findIndex((flatItem) => getItemId(flatItem) === getItemId(item)); + lastSelectedRef.current = { item, index }; + } + } + } + // Shift+Click - range selection + else if (isShiftClick) { + const flattened = getFlattenedHierarchy(); + const clickedIndex = flattened.findIndex((flatItem) => getItemId(flatItem) === getItemId(item)); + + if (clickedIndex === -1) return; + + // If no last selected item, treat as normal selection + if (!lastSelectedRef.current) { + if (!isGroup(item)) { + const asset = scene.current.getObjectByProperty("uuid", item.modelUuid); + if (asset) { + clearSelectedAssets(); + addSelectedAsset(asset); + lastSelectedRef.current = { item, index: clickedIndex }; + } + } + return; + } + + // Determine range + const startIndex = Math.min(lastSelectedRef.current.index, clickedIndex); + const endIndex = Math.max(lastSelectedRef.current.index, clickedIndex); + + // Get all items in range + const itemsInRange = flattened.slice(startIndex, endIndex + 1); + + // Filter out groups, only select assets + const assetsInRange: Asset[] = []; + itemsInRange.forEach((rangeItem) => { + if (!isGroup(rangeItem)) { + assetsInRange.push(rangeItem); + } + }); + + // If Ctrl+Shift, add to existing selection; otherwise replace + if (!isCtrlClick) { + clearSelectedAssets(); + } + + // Add all assets in range to selection + assetsInRange.forEach((assetItem) => { + const asset = scene.current!.getObjectByProperty("uuid", assetItem.modelUuid); + if (asset) { + addSelectedAsset(asset); + } + }); + } + }, + [scene.current, isGroup, getFlattenedHierarchy, clearSelectedAssets, addSelectedAsset, toggleSelectedAsset] + ); + + const handleOptionClick = useCallback( + (option: string, item: AssetGroupChild) => { + if (option === "visibility") { + if (isGroup(item)) { + } else { + const undoActions: UndoRedo3DAction[] = []; + const assetsToUpdate: AssetData[] = []; + const assetUuid = item.modelUuid; + const asset = getAssetById(assetUuid); + if (!asset) return; + + const updatedAsset = peekToggleVisibility(assetUuid); + if (!updatedAsset) return; + + assetsToUpdate.push({ + type: "Asset", + assetData: { + ...asset, + isVisible: asset.isVisible, + }, + newData: { + ...asset, + isVisible: updatedAsset.isVisible, + }, + timeStap: new Date().toISOString(), + }); + + handleAssetVisibilityUpdate(updatedAsset); + + if (assetsToUpdate.length > 0) { + if (assetsToUpdate.length === 1) { + undoActions.push({ + module: "builder", + actionType: "Asset-Update", + asset: assetsToUpdate[0], + }); + } else { + undoActions.push({ + module: "builder", + actionType: "Assets-Update", + assets: assetsToUpdate, + }); + } + + push3D({ + type: "Scene", + actions: undoActions, + }); + } + } + } else if (option === "lock") { + if (isGroup(item)) { + } else { + } + } else if (option === "kebab") { + if (isGroup(item)) { + } + } + }, + [selectedVersion, builderSocket, projectId, userId, organization] + ); + + if (!isOpen) { + return ( + setIsOpen(true)}> + + + + + ); + } + + return ( + + + + + + + + Scene Hierarchy + + setIsOpen(false)}> + + + + + + + + + + + + + + { + const { assetGroups, setGroupExpanded } = assetGroupStore.getState(); + assetGroups.forEach((group) => setGroupExpanded(group.groupUuid, true)); + }} + > + + + + + + {groupHierarchy.map((item) => ( + + ))} + + + + 1 ? "multi-selection" : ""}`}> + {selectedAssets.length > 1 ? `${selectedAssets.length} items selected` : `${groupHierarchy.length} root items`} + + + + + ); +}; diff --git a/app/src/modules/scene/camera/camMode.tsx b/app/src/modules/scene/camera/camMode.tsx index 881838c..7d2cc19 100644 --- a/app/src/modules/scene/camera/camMode.tsx +++ b/app/src/modules/scene/camera/camMode.tsx @@ -7,12 +7,17 @@ import { useCamMode, useToggleView } from "../../../store/builder/store"; import switchToThirdPerson from "./functions/switchToThirdPerson"; import switchToFirstPerson from "./functions/switchToFirstPerson"; import { detectModifierKeys } from "../../../utils/shortcutkeys/detectModifierKeys"; +import { OrthographicCamera, PerspectiveCamera } from "three"; const CamMode: React.FC = () => { const { camMode, setCamMode } = useCamMode(); const [_, get] = useKeyboardControls(); const [isTransitioning, setIsTransitioning] = useState(false); - const state: any = useThree(); + const state: { + camera: PerspectiveCamera | OrthographicCamera; + controls: any; + gl: { domElement: HTMLElement }; + } = useThree(); const { toggleView } = useToggleView(); const [isShiftActive, setIsShiftActive] = useState(false); @@ -69,15 +74,15 @@ const CamMode: React.FC = () => { } }; - window.addEventListener("keydown", handleKeyPress); - window.addEventListener("keyup", handleKeyUp); + state.gl.domElement.addEventListener("keydown", handleKeyPress); + state.gl.domElement.addEventListener("keyup", handleKeyUp); return () => { - window.removeEventListener("keydown", handleKeyPress); - window.removeEventListener("keyup", handleKeyUp); + state.gl.domElement.removeEventListener("keydown", handleKeyPress); + state.gl.domElement.removeEventListener("keyup", handleKeyUp); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camMode, isTransitioning, toggleView, state.controls, state.camera, setCamMode]); + }, [camMode, isTransitioning, toggleView, state.controls, state.camera, state.gl, setCamMode]); useFrame(() => { const { forward, backward, left, right } = get(); diff --git a/app/src/modules/scene/controls/assetControls/groupControls.tsx b/app/src/modules/scene/controls/assetControls/groupControls.tsx index 196f883..2aeb465 100644 --- a/app/src/modules/scene/controls/assetControls/groupControls.tsx +++ b/app/src/modules/scene/controls/assetControls/groupControls.tsx @@ -1,7 +1,77 @@ -import React from "react"; +import * as THREE from "three"; +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useSceneContext } from "../../sceneContext"; +import { useSocketStore } from "../../../../store/socket/useSocketStore"; +import { useContextActionStore } from "../../../../store/builder/store"; +import { getUserData } from "../../../../functions/getUserData"; +import useAssetResponseHandler from "../../../collaboration/responseHandler/useAssetResponseHandler"; +import useCallBackOnKey from "../../../../utils/hooks/useCallBackOnKey"; + +import generateUniqueAssetGroupName from "../../../builder/asset/functions/generateUniqueAssetGroupName"; +import { setAssetsApi } from "../../../../services/factoryBuilder/asset/floorAsset/setAssetsApi"; function GroupControls() { - return GroupControls; + const { projectId } = useParams(); + const { builderSocket } = useSocketStore(); + const { assetStore, undoRedo3DStore, versionStore, assetGroupStore } = useSceneContext(); + const { assetGroups, addGroup, buildHierarchy } = assetGroupStore(); + const { push3D } = undoRedo3DStore(); + const { assets, selectedAssets, pastedObjects, duplicatedObjects, movedObjects, rotatedObjects } = assetStore(); + const { updateAssetInScene, removeAssetFromScene } = useAssetResponseHandler(); + const { contextAction, setContextAction } = useContextActionStore(); + const { selectedVersion } = versionStore(); + const { userId, organization } = getUserData(); + + useEffect(() => { + console.log("assetGroups: ", assetGroups); + console.log("hierarchy: ", buildHierarchy(assets, assetGroups)); + }, [assetGroups, assets]); + + useEffect(() => { + if (contextAction === "groupAsset") { + setContextAction(null); + groupSelection(); + } + }, [contextAction]); + + const groupSelection = () => { + const assetUuids: string[] = []; + + selectedAssets.forEach((selectedAsset) => { + if (selectedAsset.userData.modelUuid) { + assetUuids.push(selectedAsset.userData.modelUuid); + } + }); + + if (assetUuids.length > 0) { + const groupName = generateUniqueAssetGroupName({ baseName: "Group", existingGroups: assetGroups }); + + const assetGroup: AssetGroup = { + groupName, + groupUuid: THREE.MathUtils.generateUUID(), + isVisible: true, + isLocked: false, + isExpanded: true, + childrens: assetUuids.map((assetUuid) => { + return { type: "Asset", childrenUuid: assetUuid }; + }), + }; + addGroup(assetGroup); + } + }; + + useCallBackOnKey( + () => { + if (selectedAssets.length > 0 && pastedObjects.length === 0 && duplicatedObjects.length === 0 && movedObjects.length === 0 && rotatedObjects.length === 0) { + groupSelection(); + } + }, + "Ctrl+G", + { dependencies: [pastedObjects, duplicatedObjects, movedObjects, rotatedObjects, selectedAssets, selectedVersion, builderSocket, projectId, userId, organization], noRepeat: true } + ); + + return null; } export default GroupControls; diff --git a/app/src/modules/scene/controls/contextControls/contextControls.tsx b/app/src/modules/scene/controls/contextControls/contextControls.tsx index bc61046..c130963 100644 --- a/app/src/modules/scene/controls/contextControls/contextControls.tsx +++ b/app/src/modules/scene/controls/contextControls/contextControls.tsx @@ -226,6 +226,11 @@ function ContextControls() { if (controls) (controls as CameraControls).enabled = true; setContextAction("duplicateAsset"); }; + const handleAssetGroup = () => { + setCanRender(false); + if (controls) (controls as CameraControls).enabled = true; + setContextAction("groupAsset"); + }; return ( <> @@ -250,7 +255,7 @@ function ContextControls() { onDuplicate={handleAssetDuplicate} onCopy={handleAssetCopy} onPaste={handleAssetPaste} - onGroup={() => console.log("Group")} + onGroup={handleAssetGroup} onArray={() => console.log("Array")} onDelete={handleAssetDelete} /> diff --git a/app/src/modules/scene/controls/controls.tsx b/app/src/modules/scene/controls/controls.tsx index d30be5d..2020454 100644 --- a/app/src/modules/scene/controls/controls.tsx +++ b/app/src/modules/scene/controls/controls.tsx @@ -1,7 +1,8 @@ -import { CameraControls } from "@react-three/drei"; -import { useRef, useEffect } from "react"; -import { useThree } from "@react-three/fiber"; import * as THREE from "three"; +import { useRef, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useThree } from "@react-three/fiber"; +import { CameraControls } from "@react-three/drei"; import * as CONSTANTS from "../../../types/world/worldConstants"; import { useToggleView, useResetCamera } from "../../../store/builder/store"; import { useSocketStore } from "../../../store/socket/useSocketStore"; @@ -19,18 +20,18 @@ import RotateControls3D from "./assetControls/rotateControls3D"; import DuplicationControls3D from "./assetControls/duplicationControls3D"; import CutCopyPasteControls3D from "./assetControls/cutCopyPasteControls3D"; import ScaleControls3D from "./assetControls/scaleControls3D"; +import VisibilityControls3D from "./assetControls/visibilityControls"; +import GroupControls from "./assetControls/groupControls"; import TransformControls3D from "./assetControls/transformControls3D"; import UndoRedo2DControls from "./undoRedoControls/undoRedo2D/undoRedo2DControls"; import UndoRedo3DControls from "./undoRedoControls/undoRedo3D/undoRedo3DControls"; import CameraShortcutsControls from "../camera/shortcutsControls/cameraShortcutsControls"; -import { useParams } from "react-router-dom"; import { getUserData } from "../../../functions/getUserData"; +import { ALPHA_ORG } from "../../../pages/Dashboard"; import { getCameraApi } from "../../../services/factoryBuilder/camera/getCameraApi"; import { setCameraApi } from "../../../services/factoryBuilder/camera/setCameraApi"; import updateCamPosition from "../camera/functions/updateCameraPosition"; -import { ALPHA_ORG } from "../../../pages/Dashboard"; -import VisibilityControls3D from "./assetControls/visibilityControls"; export default function Controls() { const controlsRef = useRef(null); @@ -186,6 +187,8 @@ export default function Controls() { + + {/* 2D */} diff --git a/app/src/modules/scene/sceneContext.tsx b/app/src/modules/scene/sceneContext.tsx index 57841ca..87a8661 100644 --- a/app/src/modules/scene/sceneContext.tsx +++ b/app/src/modules/scene/sceneContext.tsx @@ -1,4 +1,5 @@ -import { createContext, useContext, useMemo, useRef } from "react"; +import { Scene } from "three"; +import { createContext, MutableRefObject, useContext, useMemo, useRef } from "react"; import { createVersionStore, VersionStoreType } from "../../store/builder/useVersionStore"; @@ -9,6 +10,8 @@ import { createAisleStore, AisleStoreType } from "../../store/builder/useAisleSt import { createZoneStore, ZoneStoreType } from "../../store/builder/useZoneStore"; import { createFloorStore, FloorStoreType } from "../../store/builder/useFloorStore"; +import { createAssetGroupStore, AssetGroupStoreType } from "../../store/builder/useAssetGroupStore"; + import { createUndoRedo2DStore, UndoRedo2DStoreType } from "../../store/builder/useUndoRedo2DStore"; import { createUndoRedo3DStore, UndoRedo3DStoreType } from "../../store/builder/useUndoRedo3DStore"; @@ -29,6 +32,8 @@ import { createThreadsStore, ThreadStoreType } from "../../store/collaboration/u import { createCollabusersStore, CollabUsersStoreType } from "../../store/collaboration/useCollabUsersStore"; type SceneContextValue = { + scene: MutableRefObject; + versionStore: VersionStoreType; assetStore: AssetStoreType; @@ -38,6 +43,8 @@ type SceneContextValue = { zoneStore: ZoneStoreType; floorStore: FloorStoreType; + assetGroupStore: AssetGroupStoreType; + undoRedo2DStore: UndoRedo2DStoreType; undoRedo3DStore: UndoRedo3DStoreType; @@ -78,6 +85,8 @@ export function SceneProvider({ readonly layout: "Main Layout" | "Comparison Layout"; readonly layoutType: "default" | "useCase" | "tutorial" | null; }) { + const scene = useRef(null); + const versionStore = useMemo(() => createVersionStore(), []); const assetStore = useMemo(() => createAssetStore(), []); @@ -87,6 +96,8 @@ export function SceneProvider({ const zoneStore = useMemo(() => createZoneStore(), []); const floorStore = useMemo(() => createFloorStore(), []); + const assetGroupStore = useMemo(() => createAssetGroupStore(), []); + const undoRedo2DStore = useMemo(() => createUndoRedo2DStore(), []); const undoRedo3DStore = useMemo(() => createUndoRedo3DStore(), []); @@ -111,6 +122,7 @@ export function SceneProvider({ const clearStores = useMemo( () => () => { + scene.current = null; versionStore.getState().clearVersions(); assetStore.getState().clearAssets(); wallAssetStore.getState().clearWallAssets(); @@ -118,6 +130,7 @@ export function SceneProvider({ aisleStore.getState().clearAisles(); zoneStore.getState().clearZones(); floorStore.getState().clearFloors(); + assetGroupStore.getState().clearGroups(); undoRedo2DStore.getState().clearUndoRedo2D(); undoRedo3DStore.getState().clearUndoRedo3D(); eventStore.getState().clearEvents(); @@ -142,6 +155,7 @@ export function SceneProvider({ wallStore, aisleStore, zoneStore, + assetGroupStore, undoRedo2DStore, undoRedo3DStore, floorStore, @@ -162,6 +176,7 @@ export function SceneProvider({ const contextValue = useMemo( () => ({ + scene, versionStore, assetStore, wallAssetStore, @@ -169,6 +184,7 @@ export function SceneProvider({ aisleStore, zoneStore, floorStore, + assetGroupStore, undoRedo2DStore, undoRedo3DStore, eventStore, @@ -190,6 +206,7 @@ export function SceneProvider({ layoutType, }), [ + scene, versionStore, assetStore, wallAssetStore, @@ -197,6 +214,7 @@ export function SceneProvider({ aisleStore, zoneStore, floorStore, + assetGroupStore, undoRedo2DStore, undoRedo3DStore, eventStore, diff --git a/app/src/store/builder/useAssetGroupStore.ts b/app/src/store/builder/useAssetGroupStore.ts new file mode 100644 index 0000000..4e533e0 --- /dev/null +++ b/app/src/store/builder/useAssetGroupStore.ts @@ -0,0 +1,455 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; + +interface AssetGroupStore { + assetGroups: AssetGroup[]; + selectedGroups: string[]; // Array of groupUuids + groupHierarchy: AssetGroupHierarchy; + + // Group CRUD operations + addGroup: (group: AssetGroup) => { createdGroups: AssetGroup[]; updatedGroups: AssetGroup[] }; + removeGroup: (groupUuid: string) => void; + clearGroups: () => void; + setGroups: (groups: AssetGroup[]) => void; + + // Group selection + setSelectedGroups: (groupUuids: string[]) => void; + addSelectedGroup: (groupUuid: string) => void; + removeSelectedGroup: (groupUuid: string) => void; + toggleSelectedGroup: (groupUuid: string) => void; + clearSelectedGroups: () => void; + hasSelectedGroup: (groupUuid: string) => boolean; + + // Group children management + addChildToGroup: (groupUuid: string, child: { type: "Asset" | "Group"; childrenUuid: string }) => { updatedGroups: AssetGroup[] }; + removeChildFromGroup: (groupUuid: string, childUuid: string) => void; + getGroupChildren: (groupUuid: string) => { type: "Asset" | "Group"; childrenUuid: string }[]; + + // Group properties + setGroupName: (groupUuid: string, newName: string) => void; + setGroupVisibility: (groupUuid: string, isVisible: boolean) => void; + setGroupLock: (groupUuid: string, isLocked: boolean) => void; + toggleGroupVisibility: (groupUuid: string) => void; + setGroupExpanded: (groupUuid: string, isExpanded: boolean) => void; + + // Hierarchy operations + buildHierarchy: (assets: Assets, groups: AssetGroup[]) => AssetGroupHierarchy; + flattenHierarchy: (hierarchy: AssetGroupHierarchy) => { assets: Asset[]; groups: AssetGroup[] }; + getGroupHierarchy: (groupUuid: string) => AssetGroupHierarchyNode | null; + getFlatGroupAssets: (groupUuid: string, assets: Assets) => Asset[]; + getFlatGroupChildren: (groupUuid: string) => string[]; // Returns all child uuids (both assets and groups) + + // Helper functions + getGroupById: (groupUuid: string) => AssetGroup | undefined; + getGroupsContainingAsset: (assetUuid: string) => AssetGroup[]; + getGroupsContainingGroup: (childGroupUuid: string) => AssetGroup[]; + hasGroup: (groupUuid: string) => boolean; + isGroup: (item: AssetGroupChild) => item is AssetGroupHierarchyNode; + isEmptyGroup: (groupUuid: string) => boolean; +} + +export const createAssetGroupStore = () => { + return create()( + immer((set, get) => ({ + assetGroups: [], + selectedGroups: [], + groupHierarchy: [], + + // Group CRUD operations + addGroup: (group) => { + const result: { createdGroups: AssetGroup[]; updatedGroups: AssetGroup[] } = { + createdGroups: [], + updatedGroups: [], + }; + + set((state) => { + // Check if group already exists + if (state.assetGroups.some((g) => g.groupUuid === group.groupUuid)) { + return; + } + + // Find all asset children in the new group + const assetChildren = group.childrens.filter((child) => child.type === "Asset"); + const assetUuids = new Set(assetChildren.map((child) => child.childrenUuid)); + + // Remove these assets from existing groups and track updated groups + const updatedGroups: AssetGroup[] = []; + + state.assetGroups.forEach((existingGroup) => { + const originalLength = existingGroup.childrens.length; + existingGroup.childrens = existingGroup.childrens.filter((child) => !(child.type === "Asset" && assetUuids.has(child.childrenUuid))); + + // If group was modified, add to updated groups + if (existingGroup.childrens.length !== originalLength) { + updatedGroups.push({ ...existingGroup }); + } + }); + + // Add the new group + state.assetGroups.push(group); + result.createdGroups.push({ ...group }); + result.updatedGroups = updatedGroups; + }); + + return result; + }, + + removeGroup: (groupUuid) => { + set((state) => { + // First remove this group from any parent groups + state.assetGroups.forEach((group) => { + group.childrens = group.childrens.filter((child) => !(child.type === "Group" && child.childrenUuid === groupUuid)); + }); + + // Then remove the group itself + state.assetGroups = state.assetGroups.filter((g) => g.groupUuid !== groupUuid); + + // Remove from selected groups + state.selectedGroups = state.selectedGroups.filter((uuid) => uuid !== groupUuid); + }); + }, + + clearGroups: () => { + set((state) => { + state.assetGroups = []; + state.selectedGroups = []; + state.groupHierarchy = []; + }); + }, + + setGroups: (groups) => { + set((state) => { + state.assetGroups = groups; + }); + }, + + // Group selection + setSelectedGroups: (groupUuids) => { + set((state) => { + state.selectedGroups = groupUuids; + }); + }, + + addSelectedGroup: (groupUuid) => { + set((state) => { + if (!state.selectedGroups.includes(groupUuid)) { + state.selectedGroups.push(groupUuid); + } + }); + }, + + removeSelectedGroup: (groupUuid) => { + set((state) => { + state.selectedGroups = state.selectedGroups.filter((uuid) => uuid !== groupUuid); + }); + }, + + toggleSelectedGroup: (groupUuid) => { + set((state) => { + const exists = state.selectedGroups.includes(groupUuid); + if (exists) { + state.selectedGroups = state.selectedGroups.filter((uuid) => uuid !== groupUuid); + } else { + state.selectedGroups.push(groupUuid); + } + }); + }, + + clearSelectedGroups: () => { + set((state) => { + state.selectedGroups = []; + }); + }, + + hasSelectedGroup: (groupUuid: string) => { + return get().selectedGroups.includes(groupUuid); + }, + + // Group children management + addChildToGroup: (groupUuid, child) => { + const result: { updatedGroups: AssetGroup[] } = { updatedGroups: [] }; + + set((state) => { + const targetGroup = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (!targetGroup) return; + + const updatedGroups: AssetGroup[] = []; + + // 1️⃣ Remove the child from any other groups (to maintain single-parent rule) + state.assetGroups.forEach((group) => { + if (group.groupUuid === groupUuid) return; // skip target group + + const originalLength = group.childrens.length; + group.childrens = group.childrens.filter((c) => c.childrenUuid !== child.childrenUuid); + + if (group.childrens.length !== originalLength) { + updatedGroups.push({ ...group }); + } + }); + + // 2️⃣ Add the child to the target group (if not already present) + if (!targetGroup.childrens.some((c) => c.childrenUuid === child.childrenUuid)) { + targetGroup.childrens.push(child); + updatedGroups.push({ ...targetGroup }); + } + + // 3️⃣ Rebuild hierarchy after modification + state.groupHierarchy = get().buildHierarchy([], state.assetGroups); + + result.updatedGroups = updatedGroups; + }); + + return result; + }, + + removeChildFromGroup: (groupUuid, childUuid) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.childrens = group.childrens.filter((child) => child.childrenUuid !== childUuid); + state.groupHierarchy = get().buildHierarchy([], state.assetGroups); + } + }); + }, + + getGroupChildren: (groupUuid) => { + const group = get().assetGroups.find((g) => g.groupUuid === groupUuid); + return group?.childrens || []; + }, + + // Group properties + setGroupName: (groupUuid, newName) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.groupName = newName; + } + }); + }, + + setGroupVisibility: (groupUuid, isVisible) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.isVisible = isVisible; + } + }); + }, + + setGroupLock: (groupUuid, isLocked) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.isLocked = isLocked; + } + }); + }, + + toggleGroupVisibility: (groupUuid) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.isVisible = !group.isVisible; + } + }); + }, + + setGroupExpanded: (groupUuid, isExpanded) => { + set((state) => { + const group = state.assetGroups.find((g) => g.groupUuid === groupUuid); + if (group) { + group.isExpanded = isExpanded; + } + }); + }, + + // Hierarchy operations + buildHierarchy: (assets: Assets, groups: AssetGroup[]): AssetGroupHierarchy => { + const assetMap = new Map(assets.map((asset) => [asset.modelUuid, asset])); + const groupMap = new Map(groups.map((group) => [group.groupUuid, group])); + + const buildNode = (group: AssetGroup): AssetGroupHierarchyNode => { + const children: AssetGroupChild[] = []; + + group.childrens.forEach((child) => { + if (child.type === "Asset") { + const asset = assetMap.get(child.childrenUuid); + if (asset) { + children.push(asset); + // Remove from assetMap so we know it's been processed + assetMap.delete(child.childrenUuid); + } + } else if (child.type === "Group") { + const childGroup = groupMap.get(child.childrenUuid); + if (childGroup) { + children.push(buildNode(childGroup)); + } + } + }); + + return { + groupUuid: group.groupUuid, + groupName: group.groupName, + isVisible: group.isVisible, + isLocked: group.isLocked, + isExpanded: group.isExpanded, + children, + }; + }; + + // Find root groups (groups that are not children of any other group) + const childGroupUuids = new Set(); + groups.forEach((group) => { + group.childrens.forEach((child) => { + if (child.type === "Group") { + childGroupUuids.add(child.childrenUuid); + } + }); + }); + + const rootGroups = groups.filter((group) => !childGroupUuids.has(group.groupUuid)); + + // Build hierarchy starting from root groups + const hierarchy: AssetGroupHierarchy = rootGroups.map(buildNode); + + // Add remaining assets that are not in any group + const ungroupedAssets: Asset[] = []; + assetMap.forEach((asset) => { + ungroupedAssets.push(asset); + }); + + const finalHierarchy = [...hierarchy, ...ungroupedAssets]; + + set((state) => { + state.groupHierarchy = finalHierarchy; + }); + + return finalHierarchy; + }, + + flattenHierarchy: (hierarchy: AssetGroupHierarchy) => { + const assets: Asset[] = []; + const groups: AssetGroup[] = []; + const processedGroups = new Set(); + + const processNode = (node: AssetGroupChild) => { + if ("modelUuid" in node) { + // It's an Asset + assets.push(node); + } else { + // It's an AssetGroupHierarchyNode + if (!processedGroups.has(node.groupUuid)) { + groups.push({ + groupUuid: node.groupUuid, + groupName: node.groupName, + isVisible: node.isVisible, + isLocked: node.isLocked, + isExpanded: node.isExpanded, + childrens: node.children.map((child) => + "modelUuid" in child ? { type: "Asset" as const, childrenUuid: child.modelUuid } : { type: "Group" as const, childrenUuid: child.groupUuid } + ), + }); + processedGroups.add(node.groupUuid); + } + + node.children.forEach(processNode); + } + }; + + hierarchy.forEach(processNode); + return { assets, groups }; + }, + + getGroupHierarchy: (groupUuid) => { + const hierarchy = get().groupHierarchy; + + const findGroup = (nodes: AssetGroupHierarchy): AssetGroupHierarchyNode | null => { + for (const node of nodes) { + if ("groupUuid" in node && node.groupUuid === groupUuid) { + return node; + } + if ("children" in node) { + const found = findGroup(node.children); + if (found) return found; + } + } + return null; + }; + + return findGroup(hierarchy); + }, + + getFlatGroupAssets: (groupUuid, assets) => { + const groupHierarchy = get().getGroupHierarchy(groupUuid); + if (!groupHierarchy) return []; + + const flatAssets: Asset[] = []; + const assetMap = new Map(assets.map((asset) => [asset.modelUuid, asset])); + + const collectAssets = (nodes: AssetGroupChild[]) => { + nodes.forEach((node) => { + if ("modelUuid" in node) { + const asset = assetMap.get(node.modelUuid); + if (asset) flatAssets.push(asset); + } else { + collectAssets(node.children); + } + }); + }; + + collectAssets(groupHierarchy.children); + return flatAssets; + }, + + getFlatGroupChildren: (groupUuid) => { + const group = get().assetGroups.find((g) => g.groupUuid === groupUuid); + if (!group) return []; + + const allChildren: string[] = []; + + const collectChildren = (children: { type: "Asset" | "Group"; childrenUuid: string }[]) => { + children.forEach((child) => { + allChildren.push(child.childrenUuid); + if (child.type === "Group") { + const childGroup = get().assetGroups.find((g) => g.groupUuid === child.childrenUuid); + if (childGroup) { + collectChildren(childGroup.childrens); + } + } + }); + }; + + collectChildren(group.childrens); + return allChildren; + }, + + // Helper functions + getGroupById: (groupUuid) => { + return get().assetGroups.find((g) => g.groupUuid === groupUuid); + }, + + getGroupsContainingAsset: (assetUuid) => { + return get().assetGroups.filter((group) => group.childrens.some((child) => child.type === "Asset" && child.childrenUuid === assetUuid)); + }, + + getGroupsContainingGroup: (childGroupUuid) => { + return get().assetGroups.filter((group) => group.childrens.some((child) => child.type === "Group" && child.childrenUuid === childGroupUuid)); + }, + + hasGroup: (groupUuid) => { + return get().assetGroups.some((g) => g.groupUuid === groupUuid); + }, + + isGroup: (item): item is AssetGroupHierarchyNode => { + return "children" in item; + }, + + isEmptyGroup: (groupUuid) => { + const group = get().assetGroups.find((g) => g.groupUuid === groupUuid); + return !group || group.childrens.length === 0; + }, + })) + ); +}; + +export type AssetGroupStoreType = ReturnType; diff --git a/app/src/types/builderTypes.d.ts b/app/src/types/builderTypes.d.ts index 6b337ff..59d2ad3 100644 --- a/app/src/types/builderTypes.d.ts +++ b/app/src/types/builderTypes.d.ts @@ -48,17 +48,33 @@ interface Asset { type Assets = Asset[]; -interface AssetHierarchyNode { - modelUuid: string; - assetId: string; - assetName: string; +// Asset-Group + +interface AssetGroup { + groupUuid: string; + groupName: string; isVisible: boolean; isLocked: boolean; - isCollidable: boolean; - opacity: number; - children: AssetHierarchyNode[]; + isExpanded: boolean; + childrens: { + type: "Asset" | "Group"; + childrenUuid: string; + }[]; } +type AssetGroupChild = AssetGroupHierarchyNode | Asset; + +interface AssetGroupHierarchyNode { + groupUuid: string; + groupName: string; + isVisible: boolean; + isLocked: boolean; + isExpanded: boolean; + children: AssetGroupChild[]; +} + +type AssetGroupHierarchy = AssetGroupChild[]; + // Wall-Asset interface WallAsset {