added temporary ui for asset outline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -95,7 +95,7 @@ function AssetsGroup({ plane }: { readonly plane: React.MutableRefObject<THREE.M
|
||||
assets.push(item);
|
||||
});
|
||||
setAssets(assets);
|
||||
})
|
||||
});
|
||||
updateLoadingProgress(100);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
422
app/src/modules/builder/testUi/outline.css
Normal file
422
app/src/modules/builder/testUi/outline.css
Normal 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;
|
||||
}
|
||||
697
app/src/modules/builder/testUi/outline.tsx
Normal file
697
app/src/modules/builder/testUi/outline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
455
app/src/store/builder/useAssetGroupStore.ts
Normal file
455
app/src/store/builder/useAssetGroupStore.ts
Normal 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>;
|
||||
30
app/src/types/builderTypes.d.ts
vendored
30
app/src/types/builderTypes.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user