added temporary ui for asset outline

This commit is contained in:
2025-10-06 10:41:40 +05:30
parent bd67685181
commit 72f2b0f4bc
14 changed files with 1830 additions and 31 deletions

View File

@@ -1079,6 +1079,35 @@ export const SaveIcon = () => {
);
};
export const FolderIcon = ({ isOpen }: { isOpen: boolean }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="hierarchy-icon">
{isOpen ? (
<path d="M2 4h12v9a1 1 0 01-1 1H3a1 1 0 01-1-1V4z M2 4V3a1 1 0 011-1h3l1 1h6a1 1 0 011 1v1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
) : (
<path d="M2 3a1 1 0 011-1h3l1 1h6a1 1 0 011 1v8a1 1 0 01-1 1H3a1 1 0 01-1-1V3z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
)}
</svg>
);
export const CubeIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="hierarchy-icon">
<path d="M8 2l5 3v6l-5 3-5-3V5l5-3z M8 2v6 M3 5l5 3 M13 5l-5 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => {
return isOpen ? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="hierarchy-chevron">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="hierarchy-chevron">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
};
export const SaveVersionIcon = () => {
return (
<svg

View File

@@ -33,6 +33,7 @@ import { recentlyViewedApi } from "../../../services/dashboard/recentlyViewedApi
import { setAssetsApi } from "../../../services/factoryBuilder/asset/floorAsset/setAssetsApi";
import { getVersionHistoryApi } from "../../../services/factoryBuilder/versionControl/getVersionHistoryApi";
import { getUserData } from "../../../functions/getUserData";
import { Outline } from "../../../modules/builder/testUi/outline";
function MainScene() {
const { setMainState, clearComparisonState } = useSimulationState();
@@ -172,7 +173,7 @@ function MainScene() {
versionId: selectedVersion?.versionId || "",
socketId: builderSocket?.id,
organization: organization,
userId: userId
userId: userId,
};
setIsRenameMode(false);
@@ -185,6 +186,7 @@ function MainScene() {
<>
{!selectedUser && (
<>
<Outline />
<KeyPressListener />
{loadingProgress > 0 && <LoadingPage progress={loadingProgress} />}
{!isPlaying && (

View File

@@ -95,7 +95,7 @@ function AssetsGroup({ plane }: { readonly plane: React.MutableRefObject<THREE.M
assets.push(item);
});
setAssets(assets);
})
});
updateLoadingProgress(100);
}
});

View File

@@ -0,0 +1,70 @@
const generateUniqueAssetGroupName = ({ baseName = "Group", existingGroups, usedNames = new Set<string>() }: { baseName?: string; existingGroups: AssetGroup[]; usedNames?: Set<string> }): 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;

View File

@@ -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<RootState>();
const { gl, scene } = useThree();
const plane = useRef<THREE.Mesh>(null);
const csgRef = useRef<any>(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);

View File

@@ -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;
}

View File

@@ -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 (
<div className={`tree-node ${shouldShowHighlight ? "drop-target-highlight" : ""}`}>
<div
className={`tree-node-content ${isLocked ? "locked" : ""} ${!isVisible ? "hidden" : ""} ${dragState.draggedItem === item ? "dragging" : ""} ${isSelected ? "selected" : ""} ${
isMultiSelected ? "multi-selected" : ""
}`}
style={{ paddingLeft: `${level * 25 + 8}px` }}
draggable={!isLocked}
onDragStart={handleNodeDragStart}
onDragOver={handleNodeDragOver}
onDragLeave={onDragLeave}
onDrop={handleNodeDrop}
onClick={handleNodeClick}
>
{isGroupNode && (
<button className="expand-button" onClick={() => onToggleExpand(item.groupUuid, !isExpanded)}>
<ChevronIcon isOpen={isExpanded} />
</button>
)}
<div className="node-icon">{isGroupNode ? <FolderIcon isOpen={isExpanded} /> : <CubeIcon />}</div>
<RenameInput value={itemName} onRename={() => {}} canEdit={true} />
<div className="node-controls">
<button
className="control-button"
title={isVisible ? "Visible" : "Hidden"}
onClick={(e) => {
handleOptionClick(e, "visibility");
}}
>
<EyeIcon isClosed={!isVisible} />
</button>
<button
className="control-button"
title={isLocked ? "Locked" : "Unlocked"}
onClick={(e) => {
handleOptionClick(e, "lock");
}}
>
<LockIcon isLocked={isLocked} />
</button>
{isGroupNode && (
<button
className="control-button"
onClick={(e) => {
handleOptionClick(e, "kebab");
}}
>
<KebebIcon />
</button>
)}
</div>
</div>
{isGroupNode && isExpanded && item.children && (
<div className="tree-children">
{item.children.map((child) => (
<TreeNode
key={isGroup(child) ? child.groupUuid : child.modelUuid}
item={child}
level={level + 1}
dragState={dragState}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onClick={onClick}
onDrop={onDrop}
onToggleExpand={onToggleExpand}
onOptionClick={onOptionClick}
/>
))}
</div>
)}
</div>
);
};
// Main Component
export const Outline = () => {
const [isOpen, setIsOpen] = useState(true);
const lastSelectedRef = useRef<{ item: AssetGroupChild; index: number } | null>(null);
const dragStateRef = useRef<DragState>({
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 (
<button className="outline-toggle" onClick={() => setIsOpen(true)}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 6h14 M3 10h14 M3 14h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
);
}
return (
<div className="outline-overlay" onDragEnd={handleDragEnd}>
<div className="outline-card">
<div className="outline-header">
<div className="header-title">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 3h6v6H3V3z M11 3h6v6h-6V3z M3 11h6v6H3v-6z M11 11h6v6h-6v-6z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<h2>Scene Hierarchy</h2>
</div>
<button className="close-button" onClick={() => setIsOpen(false)}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M5 5l10 10 M15 5L5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="outline-toolbar">
<button className="toolbar-button" title="Add Group">
<AddIcon />
</button>
<button className="toolbar-button" title="Delete">
<DeleteIcon />
</button>
<button
className="toolbar-button"
title="Expand All"
onClick={() => {
const { assetGroups, setGroupExpanded } = assetGroupStore.getState();
assetGroups.forEach((group) => setGroupExpanded(group.groupUuid, true));
}}
>
<ChevronIcon isOpen />
</button>
</div>
<div className={`outline-content ${dragStateRef.current.isRootTarget ? "root-drop-target" : ""}`} onDragOver={handleRootDragOver} onDrop={handleDrop}>
{groupHierarchy.map((item) => (
<TreeNode
key={isGroup(item) ? item.groupUuid : item.modelUuid}
item={item}
dragState={dragStateRef.current}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
onToggleExpand={handleToggleExpand}
onOptionClick={handleOptionClick}
/>
))}
</div>
<div className="outline-footer">
<span className={`footer-stats ${selectedAssets.length > 1 ? "multi-selection" : ""}`}>
{selectedAssets.length > 1 ? `${selectedAssets.length} items selected` : `${groupHierarchy.length} root items`}
</span>
</div>
</div>
</div>
);
};

View File

@@ -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();

View File

@@ -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 <div>GroupControls</div>;
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;

View File

@@ -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}
/>

View File

@@ -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<CameraControls>(null);
@@ -186,6 +187,8 @@ export default function Controls() {
<VisibilityControls3D />
<GroupControls />
<TransformControls3D />
{/* 2D */}

View File

@@ -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<Scene | null>;
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,

View File

@@ -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<AssetGroupStore>()(
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<string>();
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<typeof createAssetGroupStore>;

View File

@@ -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 {