Merge pull request 'ui/machines: some scenen improvements' (#5058) from ui-more-2 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5058
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { ObjectRegistry } from "./ObjectRegistry";
|
import { ObjectRegistry } from "./ObjectRegistry";
|
||||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
|
||||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
// @ts-expect-error: No types for troika-three-text
|
// @ts-expect-error: No types for troika-three-text
|
||||||
@@ -23,6 +22,53 @@ const BASE_EMISSIVE = 0x0c0c0c;
|
|||||||
const BASE_SELECTED_COLOR = 0x69b0e3;
|
const BASE_SELECTED_COLOR = 0x69b0e3;
|
||||||
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
||||||
|
|
||||||
|
export function createMachineMesh() {
|
||||||
|
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: CUBE_COLOR,
|
||||||
|
emissive: CUBE_EMISSIVE,
|
||||||
|
shininess: 100,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cubeMesh = new THREE.Mesh(geometry, material);
|
||||||
|
cubeMesh.castShadow = true;
|
||||||
|
cubeMesh.receiveShadow = true;
|
||||||
|
cubeMesh.name = "cube";
|
||||||
|
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
||||||
|
|
||||||
|
const { baseMesh, baseMaterial } = createCubeBase(
|
||||||
|
BASE_COLOR,
|
||||||
|
BASE_EMISSIVE,
|
||||||
|
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cubeMesh,
|
||||||
|
baseMesh,
|
||||||
|
baseMaterial,
|
||||||
|
geometry,
|
||||||
|
material,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCubeBase(
|
||||||
|
color: THREE.ColorRepresentation,
|
||||||
|
emissive: THREE.ColorRepresentation,
|
||||||
|
geometry: THREE.BoxGeometry,
|
||||||
|
) {
|
||||||
|
const baseMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color,
|
||||||
|
emissive,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
|
||||||
|
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
|
||||||
|
baseMesh.receiveShadow = false;
|
||||||
|
return { baseMesh, baseMaterial };
|
||||||
|
}
|
||||||
|
|
||||||
export class MachineRepr {
|
export class MachineRepr {
|
||||||
public id: string;
|
public id: string;
|
||||||
public group: THREE.Group;
|
public group: THREE.Group;
|
||||||
@@ -46,31 +92,21 @@ export class MachineRepr {
|
|||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
|
||||||
this.material = new THREE.MeshPhongMaterial({
|
|
||||||
color: CUBE_COLOR,
|
|
||||||
emissive: CUBE_EMISSIVE,
|
|
||||||
shininess: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cubeMesh = new THREE.Mesh(this.geometry, this.material);
|
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
|
||||||
this.cubeMesh.castShadow = true;
|
this.cubeMesh = cubeMesh;
|
||||||
this.cubeMesh.receiveShadow = true;
|
|
||||||
this.cubeMesh.userData = { id };
|
this.cubeMesh.userData = { id };
|
||||||
this.cubeMesh.name = "cube";
|
|
||||||
this.cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
|
||||||
|
|
||||||
this.baseMesh = this.createCubeBase(
|
this.baseMesh = baseMesh;
|
||||||
BASE_COLOR,
|
|
||||||
BASE_EMISSIVE,
|
|
||||||
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
|
||||||
);
|
|
||||||
this.baseMesh.name = "base";
|
this.baseMesh.name = "base";
|
||||||
|
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
|
||||||
const label = this.createLabel(id);
|
const label = this.createLabel(id);
|
||||||
|
|
||||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||||
color: BASE_COLOR, // any color you like
|
color: BASE_COLOR,
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -104,8 +140,6 @@ export class MachineRepr {
|
|||||||
const highlightedGroups = groups
|
const highlightedGroups = groups
|
||||||
.filter(([, ids]) => ids.has(this.id))
|
.filter(([, ids]) => ids.has(this.id))
|
||||||
.map(([name]) => name);
|
.map(([name]) => name);
|
||||||
|
|
||||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
|
||||||
// Update cube
|
// Update cube
|
||||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||||
@@ -122,9 +156,6 @@ export class MachineRepr {
|
|||||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||||
);
|
);
|
||||||
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
|
||||||
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
|
||||||
// );
|
|
||||||
|
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
},
|
},
|
||||||
@@ -149,45 +180,60 @@ export class MachineRepr {
|
|||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createCubeBase(
|
|
||||||
color: THREE.ColorRepresentation,
|
|
||||||
emissive: THREE.ColorRepresentation,
|
|
||||||
geometry: THREE.BoxGeometry,
|
|
||||||
) {
|
|
||||||
const baseMaterial = new THREE.MeshPhongMaterial({
|
|
||||||
color,
|
|
||||||
emissive,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
const base = new THREE.Mesh(geometry, baseMaterial);
|
|
||||||
base.position.set(0, BASE_HEIGHT / 2, 0);
|
|
||||||
base.receiveShadow = false;
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createLabel(id: string) {
|
private createLabel(id: string) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
// 0x162324
|
||||||
const text = new Text();
|
const text = new Text();
|
||||||
text.text = id;
|
text.text = id;
|
||||||
text.font = ttf;
|
text.font = ttf;
|
||||||
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
|
text.fontSize = 0.1;
|
||||||
text.fontSize = 0.15; // relative to your cube size
|
text.color = 0xffffff;
|
||||||
text.color = 0x000000; // any THREE.Color
|
text.anchorX = "center";
|
||||||
text.anchorX = "center"; // horizontal centering
|
text.anchorY = "middle";
|
||||||
text.anchorY = "bottom"; // baseline aligns to cube top
|
text.position.set(0, 0, 0.01);
|
||||||
text.position.set(0, CUBE_SIZE + 0.05, 0);
|
|
||||||
|
|
||||||
// If you want it to always face camera:
|
|
||||||
text.userData.isLabel = true;
|
|
||||||
text.outlineWidth = 0.005;
|
text.outlineWidth = 0.005;
|
||||||
text.outlineColor = 0x333333;
|
text.outlineColor = 0x162324;
|
||||||
text.quaternion.copy(this.camera.quaternion);
|
|
||||||
|
|
||||||
// Re-render on text changes
|
// Re-render on text changes
|
||||||
text.sync(() => {
|
text.sync(() => {
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
});
|
});
|
||||||
return text;
|
|
||||||
|
// --- Background (rounded rect) ---
|
||||||
|
const padding = 0.01;
|
||||||
|
// TODO: compute from text.bounds after sync
|
||||||
|
const bgWidth = text.text.length * 0.1 + padding;
|
||||||
|
const bgHeight = 0.1 + 2 * padding;
|
||||||
|
|
||||||
|
const bgGeom = new THREE.PlaneGeometry(bgWidth, bgHeight, 1, 1);
|
||||||
|
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 }); // dark gray
|
||||||
|
const bg = new THREE.Mesh(bgGeom, bgMat);
|
||||||
|
bg.position.set(0, 0, -0.01); // slightly behind text
|
||||||
|
|
||||||
|
// --- Arrow (triangle pointing down) ---
|
||||||
|
const arrowShape = new THREE.Shape();
|
||||||
|
arrowShape.moveTo(-0.05, 0);
|
||||||
|
arrowShape.lineTo(0.05, 0);
|
||||||
|
arrowShape.lineTo(0, -0.08);
|
||||||
|
arrowShape.closePath();
|
||||||
|
|
||||||
|
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
|
||||||
|
const arrow = new THREE.Mesh(arrowGeom, bgMat);
|
||||||
|
arrow.position.set(0, -bgHeight / 2, -0.001);
|
||||||
|
|
||||||
|
// --- Group ---
|
||||||
|
group.add(bg);
|
||||||
|
group.add(arrow);
|
||||||
|
group.add(text);
|
||||||
|
|
||||||
|
// Position above cube
|
||||||
|
group.position.set(0, CUBE_SIZE + 0.3, 0);
|
||||||
|
|
||||||
|
// Billboard
|
||||||
|
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
|
||||||
|
group.quaternion.copy(this.camera.quaternion);
|
||||||
|
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(scene: THREE.Scene) {
|
dispose(scene: THREE.Scene) {
|
||||||
@@ -197,12 +243,13 @@ export class MachineRepr {
|
|||||||
|
|
||||||
this.geometry.dispose();
|
this.geometry.dispose();
|
||||||
this.material.dispose();
|
this.material.dispose();
|
||||||
|
|
||||||
|
this.group.clear();
|
||||||
|
|
||||||
for (const child of this.cubeMesh.children) {
|
for (const child of this.cubeMesh.children) {
|
||||||
if (child instanceof THREE.Mesh)
|
if (child instanceof THREE.Mesh)
|
||||||
(child.material as THREE.Material).dispose();
|
(child.material as THREE.Material).dispose();
|
||||||
|
|
||||||
if (child instanceof CSS2DObject) child.element.remove();
|
|
||||||
|
|
||||||
if (child instanceof THREE.Object3D) child.remove();
|
if (child instanceof THREE.Object3D) child.remove();
|
||||||
}
|
}
|
||||||
(this.baseMesh.material as THREE.Material).dispose();
|
(this.baseMesh.material as THREE.Material).dispose();
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ import { MachineManager } from "./MachineManager";
|
|||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { Menu } from "../components/ContextMenu/ContextMenu";
|
import { Menu } from "../components/ContextMenu/ContextMenu";
|
||||||
import { clearHighlight, setHighlightGroups } from "./highlightStore";
|
import {
|
||||||
|
clearHighlight,
|
||||||
|
highlightGroups,
|
||||||
|
setHighlightGroups,
|
||||||
|
} from "./highlightStore";
|
||||||
|
import { createMachineMesh } from "./MachineRepr";
|
||||||
|
|
||||||
function intersectMachines(
|
function intersectMachines(
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
@@ -113,6 +118,7 @@ export function CubeScene(props: {
|
|||||||
// Raycaster for clicking
|
// Raycaster for clicking
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
let actionBase: THREE.Mesh | undefined;
|
let actionBase: THREE.Mesh | undefined;
|
||||||
|
let actionMachine: THREE.Group | undefined;
|
||||||
|
|
||||||
// Create background scene
|
// Create background scene
|
||||||
const bgScene = new THREE.Scene();
|
const bgScene = new THREE.Scene();
|
||||||
@@ -129,6 +135,8 @@ export function CubeScene(props: {
|
|||||||
// Managed by controls
|
// Managed by controls
|
||||||
const [isDragging, setIsDragging] = createSignal(false);
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
|
const [cancelMove, setCancelMove] = createSignal<NodeJS.Timeout>();
|
||||||
|
|
||||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||||
|
|
||||||
const [cameraInfo, setCameraInfo] = createSignal({
|
const [cameraInfo, setCameraInfo] = createSignal({
|
||||||
@@ -300,12 +308,12 @@ export function CubeScene(props: {
|
|||||||
bgCamera,
|
bgCamera,
|
||||||
);
|
);
|
||||||
|
|
||||||
controls.addEventListener("start", (e) => {
|
// controls.addEventListener("start", (e) => {
|
||||||
setIsDragging(true);
|
// setIsDragging(true);
|
||||||
});
|
// });
|
||||||
controls.addEventListener("end", (e) => {
|
// controls.addEventListener("end", (e) => {
|
||||||
setIsDragging(false);
|
// setIsDragging(false);
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||||
@@ -384,6 +392,23 @@ export function CubeScene(props: {
|
|||||||
|
|
||||||
scene.add(actionBase);
|
scene.add(actionBase);
|
||||||
|
|
||||||
|
function createActionMachine() {
|
||||||
|
const { baseMesh, cubeMesh, material, baseMaterial } =
|
||||||
|
createMachineMesh();
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(baseMesh);
|
||||||
|
group.add(cubeMesh);
|
||||||
|
// group.scale.set(0.75, 0.75, 0.75);
|
||||||
|
material.opacity = 0.6;
|
||||||
|
baseMaterial.opacity = 0.3;
|
||||||
|
baseMaterial.emissive.set(MOVE_BASE_EMISSIVE);
|
||||||
|
// Hide until needed
|
||||||
|
group.visible = false;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
actionMachine = createActionMachine();
|
||||||
|
scene.add(actionMachine);
|
||||||
|
|
||||||
// const spherical = new THREE.Spherical();
|
// const spherical = new THREE.Spherical();
|
||||||
// spherical.setFromVector3(camera.position);
|
// spherical.setFromVector3(camera.position);
|
||||||
|
|
||||||
@@ -520,24 +545,69 @@ export function CubeScene(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
const intersection = intersectMachines(
|
||||||
|
e,
|
||||||
|
renderer,
|
||||||
|
camera,
|
||||||
|
machineManager,
|
||||||
|
raycaster,
|
||||||
|
);
|
||||||
|
if (e.button === 0) {
|
||||||
|
// Left button
|
||||||
|
|
||||||
|
if (worldMode() === "select" && intersection.length) {
|
||||||
|
// Disable controls to avoid conflict
|
||||||
|
controls.enabled = false;
|
||||||
|
|
||||||
|
// Change cursor to grabbing
|
||||||
|
const cancelMove = setTimeout(() => {
|
||||||
|
setIsDragging(true);
|
||||||
|
// Set machine as flying
|
||||||
|
setHighlightGroups({ move: new Set(intersection) });
|
||||||
|
setWorldMode("move");
|
||||||
|
renderLoop.requestRender();
|
||||||
|
}, 500);
|
||||||
|
setCancelMove(cancelMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const intersection = intersectMachines(
|
|
||||||
e,
|
|
||||||
renderer,
|
|
||||||
camera,
|
|
||||||
machineManager,
|
|
||||||
raycaster,
|
|
||||||
);
|
|
||||||
if (!intersection.length) return;
|
if (!intersection.length) return;
|
||||||
setMenuIntersection(intersection);
|
setMenuIntersection(intersection);
|
||||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||||
setContextOpen(true);
|
setContextOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
console.log("Left mouse up");
|
||||||
|
setIsDragging(false);
|
||||||
|
if (cancelMove()) {
|
||||||
|
clearTimeout(cancelMove()!);
|
||||||
|
setCancelMove(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worldMode() === "move") {
|
||||||
|
// Cancel long-press if it wasn't triggered yet
|
||||||
|
// Re-enable controls
|
||||||
|
controls.enabled = true;
|
||||||
|
|
||||||
|
// Set machine as not flying
|
||||||
|
props.setMachinePos(
|
||||||
|
highlightGroups["move"].values().next().value!,
|
||||||
|
cursorPosition() || null,
|
||||||
|
);
|
||||||
|
clearHighlight("move");
|
||||||
|
setWorldMode("select");
|
||||||
|
renderLoop.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
renderer.domElement.addEventListener("mousedown", handleMouseDown);
|
||||||
|
renderer.domElement.addEventListener("mouseup", handleMouseUp);
|
||||||
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
renderer.domElement.addEventListener("mousemove", onMouseMove);
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
@@ -589,14 +659,16 @@ export function CubeScene(props: {
|
|||||||
};
|
};
|
||||||
const onMouseMove = (event: MouseEvent) => {
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
||||||
if (!actionBase) return;
|
|
||||||
|
|
||||||
console.log("Mouse move in create/move mode");
|
console.log("Mouse move in create/move mode");
|
||||||
|
|
||||||
actionBase.visible = true;
|
const actionRepr = worldMode() === "create" ? actionBase : actionMachine;
|
||||||
(actionBase.material as THREE.MeshPhongMaterial).emissive.set(
|
if (!actionRepr) return;
|
||||||
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
|
||||||
);
|
actionRepr.visible = true;
|
||||||
|
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
|
||||||
|
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
|
||||||
|
// );
|
||||||
|
|
||||||
// Calculate mouse position in normalized device coordinates
|
// Calculate mouse position in normalized device coordinates
|
||||||
// (-1 to +1) for both components
|
// (-1 to +1) for both components
|
||||||
@@ -630,11 +702,11 @@ export function CubeScene(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
|
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
|
||||||
Math.abs(actionBase.position.z - snapped.z) > 0.01
|
Math.abs(actionRepr.position.z - snapped.z) > 0.01
|
||||||
) {
|
) {
|
||||||
// Only request render if the position actually changed
|
// Only request render if the position actually changed
|
||||||
actionBase.position.set(snapped.x, 0, snapped.z);
|
actionRepr.position.set(snapped.x, 0, snapped.z);
|
||||||
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
|
||||||
renderLoop.requestRender();
|
renderLoop.requestRender();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user