diff --git a/package-lock.json b/package-lock.json
index 8d3f2fa..99e66c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,15 +9,16 @@
"version": "1.0.0",
"license": "MIT",
"workspaces": [
+ "packages/engine",
+ "packages/runtime",
+ "packages/fs-tree",
"packages/project",
"packages/server",
"packages/client",
- "packages/fs-tree",
"packages/experimental",
"packages/editors/voxel-map",
"packages/editors/model",
- "packages/engine",
- "packages/runtime"
+ "packages/editors/texture"
],
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
@@ -1376,6 +1377,10 @@
"resolved": "packages/editors/model",
"link": true
},
+ "node_modules/@jolly-pixel/editor.texture": {
+ "resolved": "packages/editors/texture",
+ "link": true
+ },
"node_modules/@jolly-pixel/editor.voxel-map": {
"resolved": "packages/editors/voxel-map",
"link": true
@@ -7666,6 +7671,22 @@
"@types/three": "^0.180.0"
}
},
+ "packages/editors/texture": {
+ "name": "@jolly-pixel/editor.texture",
+ "version": "1.0.0",
+ "dependencies": {
+ "@jolly-pixel/engine": "1.0.0",
+ "@jolly-pixel/fs-tree": "1.0.0",
+ "lit": "3.3.1",
+ "three": "^0.179.1"
+ }
+ },
+ "packages/editors/texture/node_modules/three": {
+ "version": "0.179.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz",
+ "integrity": "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==",
+ "license": "MIT"
+ },
"packages/editors/voxel-map": {
"name": "@jolly-pixel/editor.voxel-map",
"version": "1.0.0",
diff --git a/package.json b/package.json
index 5ace007..c5c977a 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"packages/client",
"packages/experimental",
"packages/editors/voxel-map",
- "packages/editors/model"
+ "packages/editors/model",
+ "packages/editors/texture"
],
"repository": {
"type": "git",
diff --git a/packages/editors/texture/index.html b/packages/editors/texture/index.html
new file mode 100644
index 0000000..193038c
--- /dev/null
+++ b/packages/editors/texture/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ UV / Texture Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editors/texture/package.json b/packages/editors/texture/package.json
new file mode 100644
index 0000000..d280ce7
--- /dev/null
+++ b/packages/editors/texture/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@jolly-pixel/editor.texture",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "prepublish": "rimraf ./dist && tsc -b",
+ "test": "tsx --test test/**/*.test.ts",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "keywords": [],
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@jolly-pixel/engine": "1.0.0",
+ "@jolly-pixel/fs-tree": "1.0.0",
+ "lit": "3.3.1",
+ "three": "^0.179.1"
+ }
+}
diff --git a/packages/editors/texture/public/icons/group-closed.svg b/packages/editors/texture/public/icons/group-closed.svg
new file mode 100644
index 0000000..ffea7e7
--- /dev/null
+++ b/packages/editors/texture/public/icons/group-closed.svg
@@ -0,0 +1,79 @@
+
+
+
+
diff --git a/packages/editors/texture/public/icons/group-open.svg b/packages/editors/texture/public/icons/group-open.svg
new file mode 100644
index 0000000..8a4efd0
--- /dev/null
+++ b/packages/editors/texture/public/icons/group-open.svg
@@ -0,0 +1,79 @@
+
+
+
+
diff --git a/packages/editors/texture/public/icons/item.svg b/packages/editors/texture/public/icons/item.svg
new file mode 100644
index 0000000..7c2b719
--- /dev/null
+++ b/packages/editors/texture/public/icons/item.svg
@@ -0,0 +1,95 @@
+
+
+
+
diff --git a/packages/editors/texture/public/main.css b/packages/editors/texture/public/main.css
new file mode 100644
index 0000000..62d64e2
--- /dev/null
+++ b/packages/editors/texture/public/main.css
@@ -0,0 +1,108 @@
+* {
+ margin: 0;
+ padding: 0;
+}
+
+
+html, body {
+ height: 100%;
+ width: 100%;
+}
+
+
+body {
+ font-family: sans-serif;
+ display: flex;
+}
+
+nav {
+ width: 20%;
+ /* height: 100%; */
+ max-width: 50%;
+ min-width: 15%;
+ display: flex;
+ flex-direction: column;
+}
+
+#menu {
+ margin-bottom: 10px;
+ padding: 10px;
+}
+
+.resizer {
+ width: 5px;
+ background: #aaa;
+ cursor: col-resize;
+ height: 100%;
+}
+
+#container {
+ position: relative;
+ background-color: #e0e0e0;
+ flex-grow: 1;
+}
+
+/* canvas {
+ image-rendering: pixelated;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 0;
+ width: 100%;
+ height: 100%;
+ background-color: #444;
+ box-sizing: border-box;
+} */
+
+/* svg {
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+ width: 100%;
+ height: 100%;
+} */
+
+/* rect.uv {
+ stroke: red;
+ stroke-width: 0.5;
+ fill: none;
+ cursor: move;
+} */
+
+rect.selectedUv {
+ stroke: blue;
+}
+
+.color-button {
+ width: 24px;
+ height: 24px;
+ border: 1px solid #999;
+ display: inline-block;
+ margin-right: 5px;
+ cursor: pointer;
+}
+
+.mode-active {
+ background: #b3d4fc;
+ border: 2px solid #1976d2;
+}
+
+.selected {
+ border: 2px solid black;
+}
+
+#lastColor {
+ width:24px;
+ height:24px;
+ border: 2px solid #333;
+ box-sizing: border-box;
+ display:inline-block;
+ border:1px solid #999;
+ vertical-align: middle;
+ margin-left:10px;
+}
+
+section {
+ flex-grow: 1;
+ background: black;
+}
diff --git a/packages/editors/texture/src/components/LeftPanel.ts b/packages/editors/texture/src/components/LeftPanel.ts
new file mode 100644
index 0000000..a9848c1
--- /dev/null
+++ b/packages/editors/texture/src/components/LeftPanel.ts
@@ -0,0 +1,249 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+import { state, query } from "lit/decorators.js";
+
+// Import Internal Dependencies
+import CanvasManager from "../texture/CanvasManager.js";
+import "./tabs/Paint.js";
+import "./tabs/Build.js";
+
+// CONSTANTS
+const kBuildComponentSelector = "jolly-model-editor-build";
+const kPaintComponentSelector = "jolly-model-editor-paint";
+const kTextureSize = { x: 64, y: 64 };
+const kDefaultZoom = {
+ default: 4,
+ min: 1,
+ max: 32,
+ sensitivity: 0.1
+};
+
+export class LeftPanel extends LitElement {
+ @state()
+ declare mode: "paint" | "build" | "animate";
+
+ @query(kBuildComponentSelector)
+ declare buildComponent: any;
+
+ @query(kPaintComponentSelector)
+ declare paintComponent: any;
+
+ private canvasManager: CanvasManager;
+
+ private lastReparentedMode: "paint" | "build" | "animate" | null = null;
+ private hasInitializedCenter: boolean = false;
+ private hasInitialized: boolean = false;
+
+ static override styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ min-width: 300px;
+ width: 300px;
+ height: 100%;
+ background: green;
+ flex-shrink: 0;
+ }
+
+ ul {
+ height: 40px;
+ width: 100%;
+ margin: 0;
+ margin-bottom: 5px;
+ padding: 0;
+ display: flex;
+
+ box-sizing: border-box;
+ border: 2px solid #222;
+ border-radius: 5px;
+
+ list-style: none;
+ }
+
+ ul > li {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ background: #AAA;
+ border: 2px solid #CCC;
+ font-weight: normal;
+ }
+
+ ul > li:hover {
+ color: #fff;
+ cursor: pointer;
+ background: #444;
+ }
+
+ ul > li.mode-active {
+ background: #222;
+ color: #fff;
+ font-weight: bold;
+ }
+
+ #leftPanelContent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.mode = "build";
+
+ // Create a temporary container for the CanvasManager
+ const containerDiv = document.createElement("div");
+
+ // Initialize CanvasManager with the temporary container
+ this.canvasManager = new CanvasManager(containerDiv, {
+ texture: { size: kTextureSize },
+ defaultMode: "move",
+ zoom: kDefaultZoom,
+ brush: {
+ size: 8
+ }
+ });
+ }
+
+ public getSharedCanvasManager(): CanvasManager {
+ return this.canvasManager;
+ }
+
+ public getActiveComponent(): any {
+ const selector = (this.mode === "build") ? kBuildComponentSelector : kPaintComponentSelector;
+
+ return this.renderRoot.querySelector(selector);
+ }
+
+ private updateCanvasMode(): void {
+ const newMode = (this.mode === "paint") ? "paint" : "move";
+ this.canvasManager.setMode(newMode);
+ }
+
+ private handleTabClick(tabMode: "paint" | "build" | "animate"): void {
+ this.mode = tabMode;
+ }
+
+ private syncTextureSizeInputs(): void {
+ const textureSize = this.canvasManager.getTextureSize();
+
+ if (!this.buildComponent) {
+ return;
+ }
+
+ const textureSizeXInput = this.buildComponent.renderRoot.querySelector("#textureSizeX") as HTMLSelectElement;
+ const textureSizeYInput = this.buildComponent.renderRoot.querySelector("#textureSizeY") as HTMLSelectElement;
+
+ if (textureSizeXInput) {
+ textureSizeXInput.value = String(textureSize.x);
+ }
+
+ if (textureSizeYInput) {
+ textureSizeYInput.value = String(textureSize.y);
+ }
+ }
+
+ private async initializeReparenting(): Promise {
+ if (!this.buildComponent || !this.paintComponent) {
+ throw new Error("LeftPanel: Build or Paint component not found");
+ }
+
+ // Wait for both components to be fully updated
+ await Promise.all([this.buildComponent.updateComplete, this.paintComponent.updateComplete]);
+
+ if (!this.buildComponent.texturePreviewElement || !this.paintComponent.texturePreviewElement) {
+ throw new Error("LeftPanel: texturePreviewElement not found on Build or Paint component");
+ }
+
+ // Reparent to the active component and center texture
+ this.reparentCanvasToActiveTab();
+
+ // Synchronize texture size inputs with CanvasManager state
+ this.syncTextureSizeInputs();
+
+ this.hasInitialized = true;
+ }
+
+ private reparentCanvasToActiveTab(): void {
+ const activeComponent = this.getActiveComponent();
+
+ // Only reparent if we have a valid active component with preview element
+ if (!activeComponent?.texturePreviewElement) {
+ return;
+ }
+
+ if (this.lastReparentedMode !== this.mode) {
+ this.canvasManager.reparentCanvasTo(activeComponent.texturePreviewElement);
+
+ if (!this.hasInitializedCenter) {
+ this.canvasManager.centerTexture();
+ this.hasInitializedCenter = true;
+ }
+
+ this.lastReparentedMode = this.mode;
+ }
+ }
+
+ override async firstUpdated(): Promise {
+ try {
+ await this.initializeReparenting();
+ }
+ catch (error) {
+ console.error(error);
+ }
+ }
+
+ override updated(): void {
+ if (!this.hasInitialized) {
+ return;
+ }
+
+ try {
+ this.updateCanvasMode();
+ this.reparentCanvasToActiveTab();
+ this.canvasManager.onResize();
+ }
+ catch (error) {
+ console.error(error);
+ }
+ }
+
+ override render() {
+ return html`
+
+ - this.handleTabClick("build")}"
+ class="${this.mode === "build" ? "mode-active" : ""}"
+ >
+ Build
+
+ - this.handleTabClick("paint")}"
+ class="${this.mode === "paint" ? "mode-active" : ""}"
+ >
+ Paint
+
+ - this.handleTabClick("animate")}"
+ class="${this.mode === "animate" ? "mode-active" : ""}"
+ >
+ Animate
+
+
+
+
+
+
+ `;
+ }
+}
+
+customElements.define("jolly-model-editor-left-panel", LeftPanel);
diff --git a/packages/editors/texture/src/components/PopupManager.ts b/packages/editors/texture/src/components/PopupManager.ts
new file mode 100644
index 0000000..1e7748c
--- /dev/null
+++ b/packages/editors/texture/src/components/PopupManager.ts
@@ -0,0 +1,85 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+
+// Import Internal Dependencies
+import type ThreeSceneManager from "../three/ThreeSceneManager.js";
+
+export class PopupManager extends LitElement {
+ private sceneManager: ThreeSceneManager | null = null;
+
+ static override styles = css`
+ :host {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1000;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ }
+
+ :host(.active) {
+ display: flex;
+ }
+
+ .overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ }
+
+ .container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ `;
+
+ override firstUpdated() {
+ // No need to query .container since we use slot now
+ }
+
+ public show(component: HTMLElement) {
+ // Clear existing children and add the new component
+ this.innerHTML = "";
+ this.appendChild(component);
+ this.classList.add("active");
+ if (this.sceneManager) {
+ this.sceneManager.setControlsEnabled(false);
+ }
+ }
+
+ public hide() {
+ this.classList.remove("active");
+ this.innerHTML = "";
+ if (this.sceneManager) {
+ this.sceneManager.setControlsEnabled(true);
+ }
+ }
+
+ public setSceneManager(sceneManager: ThreeSceneManager): void {
+ this.sceneManager = sceneManager;
+ }
+
+ override render() {
+ return html`
+
+ `;
+ }
+}
+
+customElements.define("jolly-popup-manager", PopupManager);
diff --git a/packages/editors/texture/src/components/Resizer.ts b/packages/editors/texture/src/components/Resizer.ts
new file mode 100644
index 0000000..da824ee
--- /dev/null
+++ b/packages/editors/texture/src/components/Resizer.ts
@@ -0,0 +1,58 @@
+// Import Third-party Dependencies
+import { LitElement, css } from "lit";
+import { property } from "lit/decorators.js";
+
+export type ModeResizer = "horizontal" | "vertical";
+
+export class Resizer extends LitElement {
+ @property({ type: String })
+ declare name: string;
+
+ @property({ type: String })
+ declare mode: ModeResizer;
+
+ static override styles = css`
+ :host {
+ width: 5px;
+ background: red;
+ flex-basis: 5px;
+ flex-shrink: 0;
+ }
+ `;
+
+ constructor() {
+ super();
+
+ this.name = "resizer";
+ this.mode = "horizontal";
+
+ this.style.cursor = this.mode === "horizontal" ? "col-resize" : "row-resize";
+
+ this.addEventListener("pointerdown", this.startDrag.bind(this));
+ }
+
+ startDrag(e: PointerEvent) {
+ this.setPointerCapture(e.pointerId);
+ let start = this.mode === "horizontal" ? e.clientX : e.clientY;
+ const onMove = (moveEvent: PointerEvent) => {
+ const delta = this.mode === "horizontal" ? moveEvent.clientX - start : moveEvent.clientY - start;
+ start = this.mode === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
+
+ this.dispatchEvent(new CustomEvent("resizer", {
+ detail: { delta, name: this.name },
+ bubbles: true,
+ composed: true
+ }));
+ };
+
+ function onUp() {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ }
+
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ }
+}
+
+customElements.define("jolly-resizer", Resizer);
diff --git a/packages/editors/texture/src/components/RightPanel.ts b/packages/editors/texture/src/components/RightPanel.ts
new file mode 100644
index 0000000..28def3e
--- /dev/null
+++ b/packages/editors/texture/src/components/RightPanel.ts
@@ -0,0 +1,328 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+import { TreeView } from "@jolly-pixel/fs-tree/tree-view";
+
+// Import Internal Dependencies
+import type ModelManager from "../three/ModelManager.js";
+import type GroupManager from "../three/GroupManager.js";
+import type { PopupManager } from "./PopupManager.js";
+import { AddMeshPopup } from "./popups/index.js";
+
+export class RightPanel extends LitElement {
+ private treeView: TreeView;
+ private modelManager: ModelManager | null = null;
+ private popupManager: PopupManager | null = null;
+ private uuidToListItemMap: Map = new Map();
+
+ static override styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 300px;
+ min-width: 200px;
+ flex-shrink: 0;
+ border-left: 2px solid var(--jolly-border-color);
+ box-sizing: border-box;
+ background: purple;
+ }
+ nav {
+ width: 100%;
+ padding: 5px;
+ box-sizing: border-box;
+ }
+
+ ul {
+ flex-grow: 1;
+ height: 40px;
+ margin: 0;
+ padding: 0;
+ display: flex;
+
+ box-sizing: border-box;
+ border: 2px solid var(--jolly-border-color);
+ border-radius: 5px;
+
+ list-style: none;
+ background: var(--jolly-surface-color);
+ gap: 5px;
+ }
+ ul > li {
+ flex: 1;
+ display: flex;
+ }
+
+ ul > li > button {
+ flex-grow: 1;
+ background: green;
+ border: none;
+ cursor: pointer;
+ font-size: 1em;
+ padding: 0;
+ margin: 0;
+ color: var(--jolly-text-color);
+ }
+ section {
+ flex-grow: 1;
+ width: 100%;
+ box-sizing: border-box;
+ border-bottom: 2px solid var(--jolly-border-color);
+ }
+
+ section {
+ background: orange;
+ }
+
+ ol.tree {
+ position: absolute;
+ list-style: none;
+ line-height: 1.5;
+ margin: 0;
+ padding: 0.25em 0.25em 2em 0.25em;
+ width: 100%;
+ min-height: 100%;
+ }
+
+ ol.tree * {
+ user-select: none;
+ }
+
+ ol.tree.drop-inside:before {
+ position: absolute;
+ content: "";
+ border-top: 1px solid #888;
+ left: 0.25em;
+ right: 0.25em;
+ top: 0.25em;
+ }
+
+ ol.tree ol {
+ list-style: none;
+ margin: 0;
+ padding-left: 24px;
+ }
+
+ ol.tree ol:last-of-type.drop-below {
+ border-bottom: 1px solid #888;
+ padding-bottom: 0;
+ }
+
+ ol.tree li.item,
+ ol.tree li.group {
+ background-clip: border-box;
+ height: 28px;
+ display: flex;
+ padding: 1px;
+ cursor: default;
+ display: flex;
+ align-items: center;
+ }
+
+ ol.tree li.item>.icon,
+ ol.tree li.group>.icon,
+ ol.tree li.item>.toggle,
+ ol.tree li.group>.toggle {
+ margin: -1px;
+ width: 24px;
+ height: 24px;
+ }
+
+ ol.tree li.item span,
+ ol.tree li.group span {
+ align-self: center;
+ padding: 0.25em;
+ }
+
+ ol.tree li.item:hover,
+ ol.tree li.group:hover {
+ background-color: #eee;
+ }
+
+ ol.tree li.item.drop-above,
+ ol.tree li.group.drop-above {
+ border-top: 1px solid #888;
+ padding-top: 0;
+ }
+
+ ol.tree li.item.drop-inside,
+ ol.tree li.group.drop-inside {
+ border: 1px solid #888;
+ padding: 0;
+ }
+
+ ol.tree li.item.selected,
+ ol.tree li.group.selected {
+ background: #beddf4;
+ }
+
+ ol.tree li.item>.icon {
+ background-image: url("./icons/item.svg");
+ }
+
+ ol.tree li.item.drop-below {
+ border-bottom: 1px solid #888;
+ padding-bottom: 0;
+ }
+
+ ol.tree li.group {
+ color: #444;
+ }
+
+ ol.tree li.group>.toggle {
+ background-image: url("./icons/group-open.svg");
+ cursor: pointer;
+ }
+
+ ol.tree li.group.drop-below+ol {
+ border-bottom: 1px solid #888;
+ }
+
+ ol.tree li.group.drop-below+ol:empty {
+ margin-top: -1px;
+ pointer-events: none;
+ }
+
+ ol.tree li.group.collapsed>.toggle {
+ background-image: url("./icons/group-closed.svg");
+ }
+
+ ol.tree li.group.collapsed+ol>ol,
+ ol.tree li.group.collapsed+ol>li {
+ display: none;
+ }
+ `;
+
+ override firstUpdated() {
+ this.initTreeView();
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ document.addEventListener("groupCreated", (e: Event) => {
+ const event = e as CustomEvent;
+ const { group, name } = event.detail;
+ this.addGroupItemToUI(group, name || "Cube");
+ });
+
+ document.addEventListener("groupSelected", (e: Event) => {
+ const event = e as CustomEvent;
+ const { group } = event.detail;
+ const uuid = group ? group.getGroupUUID() : null;
+ this.setSelectedItemInUI(uuid);
+ });
+
+ // Listen to TreeView selection changes
+ this.treeView.addEventListener("selectionChange", () => {
+ this.handleTreeViewSelectionChange();
+ });
+ }
+
+ private handleTreeViewSelectionChange(): void {
+ const selectedNodes = this.treeView.selector.nodes;
+
+ if (selectedNodes.length === 0) {
+ if (this.modelManager) {
+ this.modelManager.selectGroup(null);
+ }
+
+ return;
+ }
+
+ const selectedElement = selectedNodes[0] as HTMLLIElement;
+ const uuid = selectedElement.getAttribute("data-uuid");
+
+ if (!uuid || !this.modelManager) {
+ return;
+ }
+
+ const group = this.modelManager.getGroupByUUID(uuid);
+ if (group) {
+ this.modelManager.selectGroup(group);
+ }
+ }
+
+ private initTreeView() {
+ const treeViewContainer = this.shadowRoot?.querySelector("section") as HTMLDivElement;
+ this.treeView = new TreeView(treeViewContainer);
+ }
+
+ public setModelManager(modelManager: ModelManager): void {
+ this.modelManager = modelManager;
+ }
+
+ public setPopupManager(popupManager: PopupManager): void {
+ this.popupManager = popupManager;
+ }
+
+ public addGroupItemToUI(group: GroupManager, label: string = "Cube"): void {
+ const uuid = group.getGroupUUID();
+ const itemElt = document.createElement("li");
+ itemElt.setAttribute("data-uuid", uuid);
+ itemElt.classList.add("item");
+
+ const iconElt = document.createElement("i");
+ iconElt.classList.add("icon");
+ itemElt.appendChild(iconElt);
+
+ const spanElt = document.createElement("span");
+ spanElt.textContent = label;
+ itemElt.appendChild(spanElt);
+
+ this.treeView.append(itemElt, "group");
+ this.uuidToListItemMap.set(uuid, itemElt);
+ }
+
+ public setSelectedItemInUI(uuid: string | null): void {
+ const selectedItem = uuid ? this.uuidToListItemMap.get(uuid) : null;
+
+ // Update TreeView selector to keep it in sync
+ this.treeView.selector.clear();
+ if (selectedItem) {
+ this.treeView.selector.add(selectedItem);
+ }
+ }
+
+ private handleAddCube(): void {
+ if (!this.popupManager) {
+ return;
+ }
+
+ const addMeshPopup = new AddMeshPopup({
+ title: "New Cube",
+ placeholder: "Cube",
+ onConfirm: (name) => {
+ this.createCubeWithName(name || "Cube");
+ this.popupManager?.hide();
+ },
+ onCancel: () => {
+ this.popupManager?.hide();
+ }
+ });
+
+ this.popupManager.show(addMeshPopup);
+ }
+
+ private createCubeWithName(name: string): void {
+ const event = new CustomEvent("addcube", {
+ detail: { name },
+ bubbles: true,
+ composed: true
+ });
+ this.dispatchEvent(event);
+ }
+
+ override render() {
+ return html`
+
+
+ `;
+ }
+}
+
+customElements.define("jolly-model-editor-right-panel", RightPanel);
diff --git a/packages/editors/texture/src/components/popups/AddMeshPopup.ts b/packages/editors/texture/src/components/popups/AddMeshPopup.ts
new file mode 100644
index 0000000..b538ad3
--- /dev/null
+++ b/packages/editors/texture/src/components/popups/AddMeshPopup.ts
@@ -0,0 +1,179 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+import { property } from "lit/decorators.js";
+
+export class AddMeshPopup extends LitElement {
+ @property()
+ declare public title: string;
+
+ @property()
+ declare public placeholder: string;
+
+ @property()
+ declare public onConfirm: ((name: string) => void) | null;
+
+ @property()
+ declare public onCancel: (() => void) | null;
+
+ private inputValue: string = "";
+
+ constructor(options?: {
+ title?: string;
+ placeholder?: string;
+ onConfirm?: (name: string) => void;
+ onCancel?: () => void;
+ }) {
+ super();
+ this.title = options?.title || "Enter Name";
+ this.placeholder = options?.placeholder || "Name...";
+ this.onConfirm = options?.onConfirm || null;
+ this.onCancel = options?.onCancel || null;
+ }
+
+ static override styles = css`
+ :host {
+ --popup-background: white;
+ --popup-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 10px 13px rgba(0, 0, 0, 0.1);
+ --popup-border-radius: 8px;
+ --popup-text-color: #333;
+ --popup-border-color: #ddd;
+ --popup-primary-color: #4a90e2;
+ --popup-primary-hover: #357abd;
+ }
+
+ .popup {
+ background: var(--popup-background);
+ border-radius: var(--popup-border-radius);
+ box-shadow: var(--popup-shadow);
+ padding: 24px;
+ min-width: 300px;
+ max-width: 500px;
+ position: relative;
+ z-index: 1001;
+ }
+
+ .popup h2 {
+ margin: 0 0 12px 0;
+ font-size: 1.25em;
+ color: var(--popup-text-color);
+ }
+
+ .popup input {
+ width: 100%;
+ padding: 8px 12px;
+ margin-bottom: 16px;
+ border: 1px solid var(--popup-border-color);
+ border-radius: 4px;
+ font-size: 1em;
+ box-sizing: border-box;
+ font-family: inherit;
+ }
+
+ .popup input:focus {
+ outline: none;
+ border-color: var(--popup-primary-color);
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
+ }
+
+ .buttons {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+
+ button {
+ padding: 8px 16px;
+ border: 1px solid var(--popup-border-color);
+ border-radius: 4px;
+ background: var(--popup-background);
+ color: var(--popup-text-color);
+ cursor: pointer;
+ font-size: 0.95em;
+ transition: all 0.2s;
+ font-family: inherit;
+ }
+
+ button:hover {
+ background: #f5f5f5;
+ }
+
+ button.primary {
+ background: var(--popup-primary-color);
+ color: white;
+ border-color: var(--popup-primary-hover);
+ }
+
+ button.primary:hover {
+ background: var(--popup-primary-hover);
+ }
+ `;
+
+ override render() {
+ return html`
+
+ `;
+ }
+
+ override firstUpdated() {
+ const input = this.renderRoot.querySelector("input") as HTMLInputElement;
+ if (input) {
+ setTimeout(() => input.focus(), 100);
+ }
+ }
+
+ private handleInput = (e: Event) => {
+ const target = e.target as HTMLInputElement;
+ this.inputValue = target.value;
+ };
+
+ private handleConfirm = () => {
+ if (this.onConfirm) {
+ this.onConfirm(this.inputValue);
+ }
+ const event = new CustomEvent("confirm", {
+ detail: { name: this.inputValue },
+ bubbles: true,
+ composed: true
+ });
+ this.dispatchEvent(event);
+ };
+
+ private handleCancel = () => {
+ if (this.onCancel) {
+ this.onCancel();
+ }
+ const event = new CustomEvent("cancel", {
+ bubbles: true,
+ composed: true
+ });
+ this.dispatchEvent(event);
+ };
+
+ private handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ this.handleConfirm();
+ }
+ else if (e.key === "Escape") {
+ e.preventDefault();
+ this.handleCancel();
+ }
+ };
+}
+
+if (!customElements.get("jolly-add-mesh-popup")) {
+ customElements.define("jolly-add-mesh-popup", AddMeshPopup);
+}
diff --git a/packages/editors/texture/src/components/popups/index.ts b/packages/editors/texture/src/components/popups/index.ts
new file mode 100644
index 0000000..7aa04ea
--- /dev/null
+++ b/packages/editors/texture/src/components/popups/index.ts
@@ -0,0 +1 @@
+export { AddMeshPopup } from "./AddMeshPopup.js";
diff --git a/packages/editors/texture/src/components/tabs/Build.ts b/packages/editors/texture/src/components/tabs/Build.ts
new file mode 100644
index 0000000..82526db
--- /dev/null
+++ b/packages/editors/texture/src/components/tabs/Build.ts
@@ -0,0 +1,281 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+import { query } from "lit/decorators.js";
+
+// Import Internal Dependencies
+import CanvasManager from "../../texture/CanvasManager.js";
+
+export class Build extends LitElement {
+ @query("#texturePreview")
+ declare texturePreviewElement: HTMLDivElement;
+
+ @query("#textureSizeX")
+ declare textureSizeXElement: HTMLSelectElement;
+
+ @query("#textureSizeY")
+ declare textureSizeYElement: HTMLSelectElement;
+
+ static override styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: 5px;
+
+ box-sizing: border-box;
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ background: orange;
+ box-sizing: border-box;
+ border: 2px solid #222;
+ border-radius: 5px;
+ }
+
+ #build {
+ padding: 5px;
+ }
+
+ ul {
+ height: 30px;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ background: blue;
+
+ box-sizing: border-box;
+
+ border: 2px solid #222;
+ border-radius: 5px;
+
+ list-style: none;
+ }
+
+ ul > li {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ background: #AAA;
+ border: 2px solid #CCC;
+ }
+
+ ul > li:hover {
+ color: #fff;
+ cursor: pointer;
+ background: #444;
+ }
+
+ #axis-input-row {
+ display: flex;
+ gap: 5px;
+ margin-top: 10px;
+ }
+
+ #axis-input-row > .axis-input {
+ position: relative;
+ flex: 1;
+ height: 40px;
+ display: flex;
+ }
+
+ #axis-input-row > .axis-input > input {
+ flex: 1;
+ border-radius: 5px;
+ padding-left: 15px;
+ box-sizing: border-box;
+ width: 100%;
+ border: 2px solid #AAA;
+ border-radius: 5px;
+ }
+
+ #axis-input-row > .axis-input > .color-indicator {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 10px;
+ height: 10px;
+ border-top-left-radius: 3px;
+ z-index: 10;
+ }
+
+ #axis-input-row > .axis-input:nth-child(1) > .color-indicator {
+ border-top: 3px solid #ff0000;
+ border-left: 3px solid #ff0000;
+ }
+
+ #axis-input-row > .axis-input:nth-child(2) > .color-indicator {
+ border-top: 3px solid #00ff00;
+ border-left: 3px solid #00ff00;
+ }
+
+ #axis-input-row > .axis-input:nth-child(3) > .color-indicator {
+ border-top: 3px solid #0000ff;
+ border-left: 3px solid #0000ff;
+ }
+
+ #texture {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-height: 300px;
+ padding-top: 5px;
+ }
+
+ #texture > .setting-row {
+ padding: 0 5px;
+ display: flex;
+ box-sizing: border-box;
+ gap: 5px;
+ }
+ #texture > .setting-row > label {
+ margin-right: 10px;
+ }
+
+ #texturePreview {
+ position: relative;
+ flex: 1;
+ width: 100%;
+ min-height: 200px;
+ background: #AAA;
+ box-sizing: border-box;
+ }
+ `;
+
+ private getLeftPanel(): any {
+ const rootNode = this.getRootNode() as ShadowRoot;
+
+ return rootNode?.host;
+ }
+
+ private getCanvasManager(): CanvasManager | null {
+ const leftPanel = this.getLeftPanel();
+
+ if (!leftPanel || typeof leftPanel.getSharedCanvasManager !== "function") {
+ return null;
+ }
+
+ return leftPanel.getSharedCanvasManager();
+ }
+
+ private handleTextureSizeChange(): void {
+ const manager = this.getCanvasManager();
+
+ if (!manager || !this.textureSizeXElement || !this.textureSizeYElement) {
+ return;
+ }
+
+ const x = parseInt(this.textureSizeXElement.value, 10);
+ const y = parseInt(this.textureSizeYElement.value, 10);
+
+ manager.setTextureSize({ x, y });
+ }
+
+ override updated(): void {
+ const manager = this.getCanvasManager();
+
+ if (!manager) {
+ console.warn("Build: No canvas manager available");
+
+ return;
+ }
+
+ if (!this.texturePreviewElement) {
+ console.error("Build: texturePreview element not found");
+
+ return;
+ }
+
+ const rect = this.texturePreviewElement.getBoundingClientRect();
+
+ if ((rect.width > 0) && (rect.height > 0)) {
+ manager.reparentCanvasTo(this.texturePreviewElement);
+ }
+ }
+
+ override render() {
+ return html`
+
+
+ - Pos
+ - Angle
+ - Size
+ - Pivot
+ - Scale
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - 0°
+ - 90°
+ - 180°
+ - 270°
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+customElements.define("jolly-model-editor-build", Build);
diff --git a/packages/editors/texture/src/components/tabs/Paint.ts b/packages/editors/texture/src/components/tabs/Paint.ts
new file mode 100644
index 0000000..3deedaa
--- /dev/null
+++ b/packages/editors/texture/src/components/tabs/Paint.ts
@@ -0,0 +1,248 @@
+// Import Third-party Dependencies
+import { LitElement, css, html } from "lit";
+import { query, state } from "lit/decorators.js";
+
+// Import Internal Dependencies
+import CanvasManager from "../../texture/CanvasManager.js";
+
+export class Paint extends LitElement {
+ @query("#texturePreview")
+ declare texturePreviewElement: HTMLDivElement;
+
+ @state()
+ declare brushColor: string;
+
+ @state()
+ declare brushSize: number;
+
+ @state()
+ declare brushOpacity: number;
+
+ static override styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ box-sizing: border-box;
+ gap: 15px;
+ }
+
+ .input-row {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: center;
+ padding: 0 5px;
+ }
+
+ .input-row > label {
+ font-weight: bold;
+ font-size: 0.9em;
+ flex-grow: 1;
+ text-align: right;
+ }
+
+ .input-row > input[type="color"] {
+ width: 30px;
+ height: 30px;
+ border: 2px solid #AAA;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .input-row >input[type="range"] {
+ width: 150px;
+ cursor: pointer;
+ }
+
+ .input-row >input[type="number"] {
+ width: 30px;
+ cursor: pointer;
+ }
+
+ .input-row > .brushSizeDisplay {
+ font-size: 0.85em;
+ color: black;
+ width: 30px;
+ text-align: center;
+ font-weight bold;
+ }
+
+ #texturePreview {
+ position: relative;
+ flex: 1;
+ width: 100%;
+ min-height: 200px;
+ background: #AAA;
+ box-sizing: border-box;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.brushColor = "#000000";
+ this.brushSize = 16;
+ this.brushOpacity = 1;
+ }
+
+ private getLeftPanel(): any {
+ const rootNode = this.getRootNode() as ShadowRoot;
+
+ return rootNode?.host;
+ }
+
+ private getCanvasManager(): CanvasManager | null {
+ const leftPanel = this.getLeftPanel();
+
+ if (!leftPanel || typeof leftPanel.getSharedCanvasManager !== "function") {
+ return null;
+ }
+
+ return leftPanel.getSharedCanvasManager();
+ }
+
+ public syncBrushInputs(): void {
+ const manager = this.getCanvasManager();
+
+ if (!manager) {
+ return;
+ }
+
+ this.brushSize = manager.brush.getSize();
+ this.brushColor = manager.brush.getColorHex();
+ this.brushOpacity = manager.brush.getOpacity();
+ }
+
+ protected override firstUpdated(): void {
+ this.syncBrushInputs();
+ this.setupColorPickListener();
+ this.handleColorChange({ target: { value: this.brushColor } } as any);
+ }
+
+ private setupColorPickListener(): void {
+ const manager = this.getCanvasManager();
+
+ if (!manager) {
+ return;
+ }
+
+ const canvas = manager.getParentHtmlElement().querySelector("canvas");
+ if (!canvas) {
+ return;
+ }
+
+ canvas.addEventListener("colorpicked", ((event: CustomEvent) => {
+ const { hex, opacity } = event.detail;
+ this.brushColor = hex;
+ this.brushOpacity = opacity;
+ this.requestUpdate();
+ }) as EventListener);
+ }
+
+ override updated(): void {
+ const manager = this.getCanvasManager();
+
+ if (!manager) {
+ console.warn("Paint: No canvas manager available");
+
+ return;
+ }
+
+ if (!this.texturePreviewElement) {
+ console.error("Paint: texturePreview element not found");
+
+ return;
+ }
+
+ const rect = this.texturePreviewElement.getBoundingClientRect();
+
+ if ((rect.width > 0) && (rect.height > 0)) {
+ manager.reparentCanvasTo(this.texturePreviewElement);
+ }
+ }
+
+ private handleColorChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this.brushColor = input.value;
+
+ const manager = this.getCanvasManager();
+
+ if (manager) {
+ manager.brush.setColor(this.brushColor);
+ }
+ }
+
+ private handleBrushSizeChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this.brushSize = parseInt(input.value, 10);
+
+ const manager = this.getCanvasManager();
+
+ if (manager) {
+ manager.brush.setSize(this.brushSize);
+ }
+ }
+
+ private handleOpacityChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this.brushOpacity = parseFloat(input.value);
+
+ const manager = this.getCanvasManager();
+
+ if (manager) {
+ manager.brush.setOpacity(this.brushOpacity);
+ }
+ }
+
+ public setCanvasTexture(canvas: HTMLCanvasElement): void {
+ const manager = this.getCanvasManager();
+
+ if (manager) {
+ manager.setTexture(canvas);
+ }
+ }
+
+ override render() {
+ return html`
+
+
+
+
+
+
+
+ ${Math.round(this.brushOpacity * 100)}%
+
+
+
+
+ ${this.brushSize}px
+
+
+
+ `;
+ }
+}
+
+customElements.define("jolly-model-editor-paint", Paint);
diff --git a/packages/editors/texture/src/index.ts b/packages/editors/texture/src/index.ts
new file mode 100644
index 0000000..a4fbc28
--- /dev/null
+++ b/packages/editors/texture/src/index.ts
@@ -0,0 +1,86 @@
+// Import Internal Dependencies
+import ThreeSceneManager from "./three/ThreeSceneManager.js";
+import "./components/LeftPanel.ts";
+import "./components/RightPanel.js";
+import "./components/PopupManager.js";
+import "./components/popups/index.js";
+import "./components/Resizer.js";
+
+const kBody = document.querySelector("body") as HTMLBodyElement;
+const leftPanel = document.querySelector("jolly-model-editor-left-panel") as HTMLElement;
+const rightPanel = document.querySelector("jolly-model-editor-right-panel") as HTMLElement;
+const popupManager = document.querySelector("jolly-popup-manager") as HTMLElement;
+const kSection = document.getElementById("threeRenderer") as HTMLDivElement;
+
+const kMinWidth = 300;
+
+const threeSceneManager = new ThreeSceneManager(kSection);
+
+// Give RightPanel access to ModelManager and PopupManager
+(rightPanel as any).setModelManager(threeSceneManager.getModelManager());
+(rightPanel as any).setPopupManager(popupManager);
+(popupManager as any).setSceneManager(threeSceneManager);
+
+function updateCanvasTexture() {
+ const leftPanelComponent = leftPanel as any;
+ const canvasManager = leftPanelComponent.getSharedCanvasManager();
+ if (canvasManager) {
+ threeSceneManager.setCanvasTexture(canvasManager);
+ }
+}
+
+requestAnimationFrame(function updateLoop() {
+ updateCanvasTexture();
+ requestAnimationFrame(updateLoop);
+});
+
+kBody.addEventListener("resizer", (e: Event) => {
+ const customEvent = e as CustomEvent;
+ const { delta, name } = customEvent.detail;
+
+ function triggerManagerResize() {
+ threeSceneManager.onResize();
+
+ // Essayer d'abord via le getSharedCanvasManager
+ const sharedManager = (leftPanel as any).getSharedCanvasManager?.();
+ if (sharedManager) {
+ sharedManager.onResize();
+ }
+ // Sinon, essayer via activeComponent
+ else {
+ const activeComponent = (leftPanel as any).getActiveComponent();
+ if (activeComponent && activeComponent.canvasManagerInstance) {
+ activeComponent.canvasManagerInstance.onResize();
+ }
+ }
+ }
+
+ if (name === "leftPanel-threejs") {
+ const leftWidth = parseInt(getComputedStyle(leftPanel).width, 10);
+ const sectionWidth = parseInt(getComputedStyle(kSection).width, 10);
+ const newLeftWidth = leftWidth + delta;
+ const newSectionWidth = sectionWidth - delta;
+
+ if (newSectionWidth >= kMinWidth) {
+ leftPanel.style.width = `${newLeftWidth}px`;
+ triggerManagerResize();
+ }
+ }
+ else if (name === "threejs-rightPanel") {
+ const rightWidth = parseInt(getComputedStyle(rightPanel).width, 10);
+ const sectionWidth = parseInt(getComputedStyle(kSection).width, 10);
+ const newRightWidth = rightWidth - delta;
+ const newSectionWidth = sectionWidth + delta;
+
+ if (newSectionWidth >= kMinWidth && newRightWidth >= kMinWidth) {
+ rightPanel.style.width = `${newRightWidth}px`;
+ triggerManagerResize();
+ }
+ }
+});
+
+rightPanel.addEventListener("addcube", (e: any) => {
+ const { name } = e.detail;
+ threeSceneManager.createCube(name);
+});
+
diff --git a/packages/editors/texture/src/texture/BrushManager.ts b/packages/editors/texture/src/texture/BrushManager.ts
new file mode 100644
index 0000000..dbe1ac8
--- /dev/null
+++ b/packages/editors/texture/src/texture/BrushManager.ts
@@ -0,0 +1,139 @@
+// Import Internal Dependencies
+import { getColorAsRGBA } from "../utils.js";
+
+export interface BrushManagerOptions {
+ color?: string;
+ size?: number;
+ maxSize?: number;
+ highlight?: {
+ colorInline?: string;
+ colorOutline?: string;
+ };
+}
+
+export default class BrushManager {
+ private color!: string;
+ private colorHex!: string;
+ private opacity: number;
+
+ private size!: number;
+ private maxSize: number;
+
+ private colorInline!: string;
+ private colorOutline!: string;
+
+ constructor(options: BrushManagerOptions = {}) {
+ this.setColor(options.color || "#000000");
+ this.maxSize = Math.max(options.maxSize || 32, 1);
+ this.setSize(options.size || this.maxSize);
+ this.opacity = 1;
+
+ this.setColorInline(options.highlight?.colorInline || "#FFF");
+ this.setColorOutline(options.highlight?.colorOutline || "#000");
+ }
+
+ setColor(color: string) {
+ this.colorHex = color;
+ const [r, g, b] = getColorAsRGBA(color);
+ this.color = `rgba(${r}, ${g}, ${b}, ${this.opacity})`;
+ }
+
+ setColorWithOpacity(color: string, opacity: number) {
+ this.colorHex = color;
+ const [r, g, b] = getColorAsRGBA(color);
+ this.opacity = Math.max(0, Math.min(1, opacity));
+ this.color = `rgba(${r}, ${g}, ${b}, ${this.opacity})`;
+ }
+
+ getColor(): string {
+ return this.color;
+ }
+
+ getColorHex(): string {
+ return this.colorHex;
+ }
+
+ setOpacity(opacity: number) {
+ if (opacity < 0) {
+ this.opacity = 0;
+
+ return;
+ }
+ if (opacity > 1) {
+ this.opacity = 1;
+
+ return;
+ }
+
+ this.opacity = opacity;
+
+ const [r, g, b] = getColorAsRGBA(this.color);
+ this.color = `rgba(${r}, ${g}, ${b}, ${this.opacity})`;
+ }
+
+ getOpacity(): number {
+ return this.opacity;
+ }
+
+ setColorInline(color: string) {
+ const [r, g, b] = getColorAsRGBA(color);
+ this.colorInline = `rgb(${r}, ${g}, ${b})`;
+ }
+
+ getColorInline() {
+ return this.colorInline;
+ }
+
+ setColorOutline(color: string) {
+ const [r, g, b] = getColorAsRGBA(color);
+ this.colorOutline = `rgb(${r}, ${g}, ${b})`;
+ }
+
+ getColorOutline() {
+ return this.colorOutline;
+ }
+
+ setSize(size: number) {
+ if (size < 1) {
+ this.size = 1;
+
+ return;
+ }
+ if (size > this.maxSize) {
+ this.size = this.maxSize;
+
+ return;
+ }
+
+ this.size = size;
+ }
+
+ getSize(): number {
+ return this.size;
+ }
+
+ getAffectedPixels(x: number, y: number): { x: number; y: number; }[] {
+ const pixels: { x: number; y: number; }[] = [];
+
+ const half = Math.floor(this.size / 2);
+ if (this.size % 2 === 0) {
+ for (let dx = -half; dx < half; dx++) {
+ for (let dy = -half; dy < half; dy++) {
+ pixels.push({ x: x + dx, y: y + dy });
+ }
+ }
+
+ return pixels;
+ }
+
+ for (let dx = -half; dx <= half; dx++) {
+ for (let dy = -half; dy <= half; dy++) {
+ pixels.push({ x: x + dx, y: y + dy });
+ }
+ }
+
+ return pixels;
+ }
+}
+
+// export { BrushManager };
diff --git a/packages/editors/texture/src/texture/CanvasManager.ts b/packages/editors/texture/src/texture/CanvasManager.ts
new file mode 100644
index 0000000..e4808ad
--- /dev/null
+++ b/packages/editors/texture/src/texture/CanvasManager.ts
@@ -0,0 +1,620 @@
+/* eslint-disable @stylistic/no-mixed-operators */
+// Import Internal Dependencies
+import BrushManager, { type BrushManagerOptions } from "./BrushManager.js";
+import SvgManager, { type SvgManagerOptions } from "./SvgManager.js";
+
+export type Mode = "paint" | "move";
+
+export interface CanvasManagerOptions {
+ defaultMode?: Mode;
+ texture?: {
+ defaultColor?: string;
+ size?: { x: number; y?: number; };
+ maxSize?: number;
+ init?: HTMLCanvasElement;
+ };
+ uv?: SvgManagerOptions;
+ zoom?: {
+ default: number;
+ sensitivity?: number;
+ min?: number;
+ max?: number;
+ };
+ backgroundTransparency?: {
+ colors: { odd: string; even: string; };
+ squareSize: number;
+ };
+ brush?: BrushManagerOptions;
+}
+
+export default class CanvasManager {
+ private parentHtmlElement: HTMLDivElement;
+ private canvas: HTMLCanvasElement;
+ private boundingRect: DOMRect;
+ private ctx: CanvasRenderingContext2D;
+
+ private backgroundColor: string;
+ private textureSize: { x: number; y: number; };
+ private textureMaxSize: number = 2048;
+
+ // Here to have backup when resizing your texture
+ private masterTextureCanvas: HTMLCanvasElement;
+ private masterTextureCtx: CanvasRenderingContext2D;
+
+ private textureCanvas: HTMLCanvasElement;
+ private textureCtx: CanvasRenderingContext2D;
+
+ private texturePixelWidth: number;
+ private texturePixelHeight: number;
+ private defaultTextureColor: string;
+
+ private backgroundTransparencyCanvas: HTMLCanvasElement;
+ private backgroundTransparencyCtx: CanvasRenderingContext2D;
+
+ private camera: { x: number; y: number; } = { x: 0, y: 0 };
+ private isPanning: boolean = false;
+ private panStart: { x: number; y: number; } = { x: 0, y: 0 };
+ // private panSensitivity: number = 0.1;
+
+ private mode: Mode;
+
+ private zoom: number;
+ private zoomMin: number;
+ private zoomMax: number;
+ private zoomSensitivity: number = 0.1;
+
+ private isDrawing: boolean = false;
+ private lastCanvasMouseDrawPos: { x: number; y: number; };
+
+ private SvgMananager: SvgManager;
+
+ public brush: BrushManager;
+
+ constructor(parentHtmlElement: HTMLDivElement, options: CanvasManagerOptions = {}) {
+ this.parentHtmlElement = parentHtmlElement;
+ this.boundingRect = this.parentHtmlElement.getBoundingClientRect();
+ this.canvas = this.initCanvasHtmlElement();
+
+ if (!this.canvas.getContext("2d")) {
+ throw new Error("Failed to get 2D context");
+ }
+ this.ctx = this.canvas.getContext("2d")!;
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.zoomMin = options.zoom?.min || 1;
+ this.zoomMax = options.zoom?.max || 32;
+ if (this.zoomMax < this.zoomMin) {
+ throw new Error(`Max zoom (${options.zoom?.max}) can't be under min zoom ${this.zoomMin}`);
+ }
+ this.zoom = Math.max(this.zoomMin, Math.min(this.zoomMax, options.zoom?.default || 4));
+ this.zoomSensitivity = options.zoom?.sensitivity || 0.1;
+
+ if (options.texture?.size === undefined) {
+ this.textureSize = { x: 64, y: 32 };
+ }
+ else {
+ this.textureSize = {
+ x: options.texture.size.x,
+ y: options.texture.size?.y || options.texture.size.x
+ };
+ }
+ this.defaultTextureColor = options.texture?.defaultColor || "#ffffff";
+ this.backgroundColor = getComputedStyle(this.parentHtmlElement).backgroundColor || "#555555";
+
+ this.texturePixelWidth = this.textureSize.x * this.zoom;
+ this.texturePixelHeight = this.textureSize.y * this.zoom;
+ this.centerTexture();
+
+ // BackgroundTransparency
+ this.backgroundTransparencyCanvas = document.createElement("canvas");
+ this.backgroundTransparencyCtx = this.backgroundTransparencyCanvas.getContext("2d")!;
+ this.initBackgroundTransparency(
+ options.backgroundTransparency?.squareSize || 8,
+ {
+ odd: options.backgroundTransparency?.colors.odd || "#999",
+ even: options.backgroundTransparency?.colors.even || "#666"
+ }
+ );
+ this.masterTextureCanvas = document.createElement("canvas");
+ this.masterTextureCtx = this.masterTextureCanvas.getContext("2d")!;
+
+ if (options.texture?.init) {
+ this.setTexture(options.texture?.init);
+ }
+ else {
+ this.textureCanvas = document.createElement("canvas");
+ this.textureCtx = this.textureCanvas.getContext("2d")!;
+ this.initTexture();
+ }
+
+ this.mode = options.defaultMode || "paint";
+
+ this.brush = new BrushManager(options.brush);
+
+ this.SvgMananager = new SvgManager(this);
+
+ this.canvas.addEventListener("mousemove", (e) => {
+ e.preventDefault();
+
+ if (this.mode === "paint" && e.button === 0) {
+ const { x, y } = this.getMouseCanvasPosition(e.clientX, e.clientY);
+ this.SvgMananager.updateBrushHighlight(x, y);
+ this.drawing(e.clientX, e.clientY);
+ }
+ });
+
+ this.canvas.addEventListener("mouseleave", () => {
+ this.SvgMananager.updateBrushHighlight(null, null);
+ });
+
+ this.canvas.addEventListener("wheel", (e) => {
+ e.preventDefault();
+
+ this.zooming(e.deltaY, e.clientX, e.clientY);
+ if (this.mode === "paint") {
+ const { x, y } = this.getMouseCanvasPosition(e.clientX, e.clientY);
+ this.SvgMananager.updateBrushHighlight(x, y);
+ }
+ }, { passive: false });
+
+ this.canvas.addEventListener("mousedown", (e) => {
+ if (this.mode === "paint" && e.button === 0) {
+ this.startDraw(e.clientX, e.clientY);
+ }
+
+ if (e.button === 1) {
+ this.startPan(e.clientX, e.clientY);
+ }
+ });
+
+ this.canvas.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ if (this.mode === "paint" && e.button === 2) {
+ const pixelColor = this.colorPicker(e.clientX, e.clientY);
+ this.brush.setColorWithOpacity(pixelColor.hex, pixelColor.opacity);
+ this.emitColorPickedEvent(pixelColor);
+ }
+ });
+
+ window.addEventListener("mousemove", (e) => {
+ this.panning(e.clientX, e.clientY);
+ });
+
+ window.addEventListener("mouseup", (e) => {
+ this.endPan();
+ if (this.isDrawing) {
+ this.endDraw(e.clientX, e.clientY);
+ }
+ });
+
+ window.addEventListener("resize", () => {
+ this.onResize();
+ });
+
+ if (this.boundingRect.width > 0 && this.boundingRect.height > 0) {
+ this.drawTexture();
+ }
+ }
+
+ getMode(): Mode {
+ return this.mode;
+ }
+ setMode(mode: Mode) {
+ this.mode = mode;
+ if (mode === "move") {
+ this.SvgMananager.hideSvgHighlight();
+ }
+ }
+
+ getParentHtmlElement(): HTMLDivElement {
+ return this.parentHtmlElement;
+ }
+
+ public reparentCanvasTo(newParentElement: HTMLDivElement): void {
+ if (!this.canvas) {
+ console.error("CanvasManager: No canvas to reparent");
+
+ return;
+ }
+
+ if (!newParentElement) {
+ console.error("CanvasManager: Invalid parent element");
+
+ return;
+ }
+
+ if (this.canvas.parentElement) {
+ this.canvas.remove();
+ }
+
+ newParentElement.appendChild(this.canvas);
+
+ this.SvgMananager.reparentSvgTo(newParentElement);
+
+ this.parentHtmlElement = newParentElement;
+
+ this.onResize();
+ }
+
+ getTextureSize(): { x: number; y: number; } {
+ return this.textureSize;
+ }
+
+ setTextureSize(size: { x: number; y: number; }): void {
+ if (size.x <= 0 || size.y <= 0) {
+ console.error("CanvasManager: Texture size must be positive");
+
+ return;
+ }
+
+ if (size.x > this.textureMaxSize || size.y > this.textureMaxSize) {
+ console.error(`CanvasManager: Texture size exceeds max size of ${this.textureMaxSize}`);
+
+ return;
+ }
+
+ const masterImageData = this.masterTextureCtx.getImageData(0, 0, this.textureMaxSize, this.textureMaxSize);
+
+ this.textureSize = size;
+
+ this.textureCanvas.width = size.x;
+ this.textureCanvas.height = size.y;
+
+ const newImageData = this.textureCtx.createImageData(size.x, size.y);
+
+ for (let y = 0; y < size.y; y++) {
+ for (let x = 0; x < size.x; x++) {
+ const masterIndex = (y * this.textureMaxSize + x) * 4;
+ const newIndex = (y * size.x + x) * 4;
+
+ newImageData.data[newIndex] = masterImageData.data[masterIndex];
+ newImageData.data[newIndex + 1] = masterImageData.data[masterIndex + 1];
+ newImageData.data[newIndex + 2] = masterImageData.data[masterIndex + 2];
+ newImageData.data[newIndex + 3] = masterImageData.data[masterIndex + 3];
+ }
+ }
+ this.textureCtx.putImageData(newImageData, 0, 0);
+
+ this.texturePixelWidth = this.textureSize.x * this.zoom;
+ this.texturePixelHeight = this.textureSize.y * this.zoom;
+
+ this.drawTexture();
+ }
+
+ getCamera(): { x: number; y: number; } {
+ return this.camera;
+ }
+
+ getZoom(): number {
+ return this.zoom;
+ }
+
+ private initCanvasHtmlElement(): HTMLCanvasElement {
+ const canvas = document.createElement("canvas");
+
+ this.parentHtmlElement.style.position = "relative";
+
+ Object.assign(canvas.style, {
+ width: "100%",
+ height: "100%",
+ position: "absolute",
+ top: "0",
+ left: "0",
+ zIndex: "0"
+ });
+
+ canvas.width = this.boundingRect.width;
+ canvas.height = this.boundingRect.height;
+ this.parentHtmlElement.appendChild(canvas);
+
+ return canvas;
+ }
+
+ private initBackgroundTransparency(squareSize: number, colors: { odd: string; even: string; }) {
+ this.backgroundTransparencyCanvas.width = this.canvas.width;
+ this.backgroundTransparencyCanvas.height = this.canvas.height;
+
+ for (let y = 0; y < this.backgroundTransparencyCanvas.height; y += squareSize) {
+ for (let x = 0; x < this.backgroundTransparencyCanvas.width; x += squareSize) {
+ const isLight = ((x / squareSize) + (y / squareSize)) % 2 === 0;
+ this.backgroundTransparencyCtx.fillStyle = isLight ? colors.odd : colors.even;
+ this.backgroundTransparencyCtx.fillRect(x, y, squareSize, squareSize);
+ }
+ }
+ }
+
+ private initTexture() {
+ this.masterTextureCanvas.width = this.textureMaxSize;
+ this.masterTextureCanvas.height = this.textureMaxSize;
+ this.textureCanvas.width = this.textureSize.x;
+ this.textureCanvas.height = this.textureSize.y;
+
+ const masterTextureImageData = this.masterTextureCtx.createImageData(this.textureMaxSize, this.textureMaxSize);
+ const textureImageData = this.textureCtx.createImageData(this.textureSize.x, this.textureSize.y);
+
+ this.masterTextureCtx.imageSmoothingEnabled = false;
+ this.textureCtx.imageSmoothingEnabled = false;
+
+ const ctx = document.createElement("canvas").getContext("2d")!;
+ ctx.fillStyle = this.defaultTextureColor;
+ ctx.fillRect(0, 0, 1, 1);
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+
+ for (let i = 0; i < masterTextureImageData.data.length; i += 4) {
+ let alpha = a;
+ if (i === 0) {
+ alpha = 0;
+ }
+ masterTextureImageData.data[i] = r;
+ masterTextureImageData.data[i + 1] = g;
+ masterTextureImageData.data[i + 2] = b;
+ masterTextureImageData.data[i + 3] = alpha;
+
+ if (i >= textureImageData.data.length) {
+ continue;
+ }
+
+ textureImageData.data[i] = r;
+ textureImageData.data[i + 1] = g;
+ textureImageData.data[i + 2] = b;
+ textureImageData.data[i + 3] = alpha;
+ }
+ this.masterTextureCtx.putImageData(masterTextureImageData, 0, 0);
+ this.textureCtx.putImageData(textureImageData, 0, 0);
+ }
+
+ private drawTexture() {
+ // Don't draw if canvas has invalid dimensions
+ if (this.canvas.width === 0 || this.canvas.height === 0) {
+ return;
+ }
+
+ // Reinitialize transform to clean the canvas easier
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ // if texture is smaller in the canvas there will be the background canvas that appears
+ // so, need to get the background color of the canvas to have the same color around the texture
+ this.ctx.fillStyle = this.backgroundColor;
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Clip on the texture position to draw bgTransparency and texture
+ this.ctx.save();
+ this.ctx.beginPath();
+ this.ctx.rect(this.camera.x, this.camera.y, this.texturePixelWidth, this.texturePixelHeight);
+ this.ctx.clip();
+
+ // Reinit transform to draw the bgTransparancy to be fixe
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.drawImage(this.backgroundTransparencyCanvas, 0, 0);
+
+ // reapply transform to draw the texture
+ this.ctx.setTransform(
+ this.zoom,
+ 0,
+ 0,
+ this.zoom,
+ this.camera.x,
+ this.camera.y
+ );
+ this.ctx.drawImage(this.textureCanvas, 0, 0);
+
+ this.ctx.restore();
+ }
+
+ private drawColor(x: number, y: number, color?: string) {
+ const pixelColor = color || this.brush.getColor();
+ const match = pixelColor.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/);
+ if (!match) {
+ return;
+ }
+
+ const r = parseInt(match[1], 10);
+ const g = parseInt(match[2], 10);
+ const b = parseInt(match[3], 10);
+ const a = match[4] === undefined ? 255 : Math.floor(parseFloat(match[4]) * 255);
+
+ const pixels = this.brush.getAffectedPixels(x, y);
+ for (const pixel of pixels) {
+ const imageData = this.textureCtx.getImageData(pixel.x, pixel.y, 1, 1);
+ const data = imageData.data;
+
+ data[0] = r;
+ data[1] = g;
+ data[2] = b;
+ data[3] = a;
+
+ this.textureCtx.putImageData(imageData, pixel.x, pixel.y);
+ }
+ this.drawTexture();
+ }
+
+ private colorPicker(x: number, y: number): { hex: string; opacity: number; } {
+ const pixelPos = this.getMouseTexturePosition(x, y, true);
+ if (pixelPos === null) {
+ return { hex: this.brush.getColorHex(), opacity: this.brush.getOpacity() };
+ }
+
+ const imageData = this.textureCtx.getImageData(pixelPos.x, pixelPos.y, 1, 1);
+ const r = imageData.data[0];
+ const g = imageData.data[1];
+ const b = imageData.data[2];
+ const a = imageData.data[3] / 255;
+
+ const hex = `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
+
+ return { hex, opacity: a };
+ }
+
+ private emitColorPickedEvent(color: { hex: string; opacity: number; }): void {
+ const event = new CustomEvent("colorpicked", {
+ detail: color,
+ bubbles: true,
+ composed: true
+ });
+ this.canvas.dispatchEvent(event);
+ }
+
+ private getMouseCanvasPosition(mouseX: number, mouseY: number): { x: number; y: number; } {
+ const x = Math.floor(mouseX - this.boundingRect.left);
+ const y = Math.floor(mouseY - this.boundingRect.top);
+
+ return { x, y };
+ }
+
+ private getMouseTexturePosition(mouseX: number, mouseY: number, limit: boolean = false):
+ { x: number; y: number; } | null {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = Math.floor((mouseX - rect.left - this.camera.x) / this.zoom);
+ const y = Math.floor((mouseY - rect.top - this.camera.y) / this.zoom);
+
+ if (limit) {
+ if (x < 0 || x >= this.textureSize.x || y < 0 || y >= this.textureSize.y) {
+ return null;
+ }
+ }
+
+ return { x, y };
+ }
+
+ private startPan(mouseX: number, mouseY: number) {
+ this.isPanning = true;
+ this.panStart = { x: mouseX, y: mouseY };
+ }
+
+ private endPan() {
+ this.isPanning = false;
+ }
+
+ private clampCamera() {
+ // Allow to keep one pixel visible when clamping
+ const margin = this.zoom;
+
+ const minX = -this.texturePixelWidth + margin;
+ const maxX = this.canvas.width - margin;
+
+ const minY = -this.texturePixelHeight + margin;
+ const maxY = this.canvas.height - margin;
+
+ this.camera.x = Math.max(minX, Math.min(maxX, this.camera.x));
+ this.camera.y = Math.max(minY, Math.min(maxY, this.camera.y));
+ }
+
+ private panning(mouseX: number, mouseY: number) {
+ if (!this.isPanning) {
+ return;
+ }
+
+ const deltaX = mouseX - this.panStart.x;
+ const deltaY = mouseY - this.panStart.y;
+
+ this.camera.x += deltaX;
+ this.camera.y += deltaY;
+
+ this.clampCamera();
+
+ this.panStart = { x: mouseX, y: mouseY };
+
+ this.drawTexture();
+ }
+
+ private zooming(deltaY: number, mouseX: number, mouseY: number) {
+ const x = mouseX - this.boundingRect.left;
+ const y = mouseY - this.boundingRect.top;
+
+ const worldX = (x - this.camera.x) / this.zoom;
+ const worldY = (y - this.camera.y) / this.zoom;
+
+ const signDelta = Math.sign(deltaY);
+ const smoothSensitivity = this.zoom - signDelta * this.zoomSensitivity < 1 || this.zoom < 1 ?
+ this.zoomSensitivity / 10 :
+ this.zoomSensitivity;
+ const newZoom = Math.max(
+ this.zoomMin,
+ Math.min(
+ this.zoomMax,
+ this.zoom - signDelta * smoothSensitivity
+ )
+ );
+
+ this.camera.x -= (worldX * newZoom - worldX * this.zoom);
+ this.camera.y -= (worldY * newZoom - worldY * this.zoom);
+
+ this.zoom = newZoom;
+
+ this.texturePixelWidth = this.textureSize.x * this.zoom;
+ this.texturePixelHeight = this.textureSize.y * this.zoom;
+
+ this.clampCamera();
+ this.drawTexture();
+ }
+
+ private startDraw(mouseX: number, mouseY: number) {
+ this.isDrawing = true;
+ const mousePos = this.getMouseTexturePosition(mouseX, mouseY)!;
+ this.drawColor(mousePos.x, mousePos.y);
+ }
+
+ private drawing(mouseX: number, mouseY: number) {
+ if (!this.isDrawing) {
+ return;
+ }
+
+ const mousePos = this.getMouseTexturePosition(mouseX, mouseY)!;
+ this.drawColor(mousePos.x, mousePos.y);
+ }
+
+ private endDraw(mouseX: number, mouseY: number) {
+ this.isDrawing = false;
+ this.lastCanvasMouseDrawPos = this.getMouseTexturePosition(mouseX, mouseY)!;
+
+ // Sync masterTexture
+ const imageData = this.textureCtx.getImageData(0, 0, this.textureSize.x, this.textureSize.y);
+ this.masterTextureCtx.putImageData(imageData, 0, 0);
+ }
+
+ centerTexture(): void {
+ this.camera.x = this.canvas.width / 2 - this.texturePixelWidth / 2;
+ this.camera.y = this.canvas.height / 2 - this.texturePixelHeight / 2;
+ this.clampCamera();
+ this.drawTexture();
+ }
+
+ // drawLine(mouseX: number, mouseY: number) {
+
+ // }
+
+ onResize() {
+ this.boundingRect = this.parentHtmlElement.getBoundingClientRect();
+
+ if (this.boundingRect.width === 0 || this.boundingRect.height === 0) {
+ return;
+ }
+
+ this.canvas.width = Math.round(this.boundingRect.width);
+ this.canvas.height = Math.round(this.boundingRect.height);
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.backgroundTransparencyCanvas.width = this.canvas.width;
+ this.backgroundTransparencyCanvas.height = this.canvas.height;
+ this.initBackgroundTransparency(
+ 8, { odd: "#999", even: "#666" }
+ );
+
+ this.SvgMananager.updateSvgSize(this.boundingRect.width, this.boundingRect.height);
+ this.clampCamera();
+
+ this.drawTexture();
+ }
+
+ getTextureCanvas(): HTMLCanvasElement {
+ return this.textureCanvas;
+ }
+
+ setTexture(canvas: HTMLCanvasElement) {
+ this.textureCanvas = canvas;
+ this.textureCtx = this.textureCanvas.getContext("2d")!;
+ }
+
+ getTexture() {
+ return this.textureCtx.getImageData(0, 0, this.textureSize.x, this.textureSize.y).data;
+ }
+}
diff --git a/packages/editors/texture/src/texture/SvgManager.ts b/packages/editors/texture/src/texture/SvgManager.ts
new file mode 100644
index 0000000..b050ccb
--- /dev/null
+++ b/packages/editors/texture/src/texture/SvgManager.ts
@@ -0,0 +1,173 @@
+/* eslint-disable @stylistic/no-mixed-operators */
+
+// Import Internal Dependencies
+import CanvasManager from "./CanvasManager.js";
+
+// CONSTANTS
+const kSvgNs = "http://www.w3.org/2000/svg";
+
+export interface SvgManagerOptions {
+ color?: string;
+ selectedColor?: string;
+ pixelSnapping?: boolean;
+ ratioPixelPerUnit?: number;
+}
+
+export default class SvgManager {
+ private parentHtmlElement: HTMLDivElement;
+ private canvasManager: CanvasManager;
+ private svg: SVGElement;
+
+ private ratioPixelPerUnit: number;
+
+ private UvColor: string;
+
+ // public
+
+ private highlightElements: SVGGElement;
+ // private UVs: number[][];
+
+ constructor(canvasManager: CanvasManager, options: SvgManagerOptions = {}) {
+ this.canvasManager = canvasManager;
+ this.parentHtmlElement = this.canvasManager.getParentHtmlElement();
+ this.svg = this.initSvgElement();
+
+ this.ratioPixelPerUnit = options.ratioPixelPerUnit || 16;
+
+ this.UvColor = options.color || "red";
+
+ this.highlightElements = this.initBrushHighlight();
+ }
+
+ private initSvgElement() {
+ const svg = document.createElementNS(kSvgNs, "svg");
+
+ Object.assign(svg.style, {
+ width: "100%",
+ height: "100%",
+ position: "absolute",
+ top: "0",
+ left: "0",
+ zIndex: "1",
+ pointerEvents: "none"
+ });
+
+ const boundingRect = this.parentHtmlElement.getBoundingClientRect();
+ svg.setAttribute("width", String(boundingRect.width));
+ svg.setAttribute("height", String(boundingRect.height));
+
+ this.parentHtmlElement.appendChild(svg);
+
+ return svg;
+ }
+
+ private initBrushHighlight(): SVGGElement {
+ const highlightGroupElement = document.createElementNS(kSvgNs, "g");
+
+ const defaultStyle = {
+ pointerEvents: "none",
+ strokeWidth: 2
+ };
+
+ const highlightElementInLine = document.createElementNS(kSvgNs, "rect");
+ Object.assign(highlightElementInLine.style, defaultStyle);
+ highlightElementInLine.setAttribute("stroke", this.canvasManager.brush.getColorInline());
+ highlightElementInLine.setAttribute("fill", "none");
+ highlightElementInLine.setAttribute("x", "0.01");
+ highlightElementInLine.setAttribute("y", "0.01");
+ highlightElementInLine.setAttribute("width", "0.98");
+ highlightElementInLine.setAttribute("height", "0.98");
+ highlightElementInLine.setAttribute("vector-effect", "non-scaling-stroke");
+ highlightGroupElement.appendChild(highlightElementInLine);
+
+ const highlightElementOutLine = document.createElementNS(kSvgNs, "rect");
+ Object.assign(highlightElementOutLine.style, defaultStyle);
+ highlightElementOutLine.setAttribute("stroke", this.canvasManager.brush.getColorOutline());
+ highlightElementOutLine.setAttribute("fill", "none");
+ highlightElementOutLine.setAttribute("width", "1");
+ highlightElementOutLine.setAttribute("height", "1");
+ highlightElementOutLine.setAttribute("vector-effect", "non-scaling-stroke");
+ highlightGroupElement.appendChild(highlightElementOutLine);
+
+ this.svg.appendChild(highlightGroupElement);
+
+ return highlightGroupElement;
+ }
+
+ updateBrushHighlight(x: number | null, y: number | null) {
+ // Si x ou y est null, masquer le highlight
+ if (x === null || y === null) {
+ this.hideSvgHighlight();
+
+ return;
+ }
+
+ const zoom = this.canvasManager.getZoom();
+ const camera = this.canvasManager.getCamera();
+ const brushSize = this.canvasManager.brush.getSize();
+ const highlightBrushSize = brushSize * zoom;
+
+ const offsetX = camera.x % zoom;
+ const offsetY = camera.y % zoom;
+
+ const gridedX = x - (x - offsetX) % zoom;
+ const gridedY = y - (y - offsetY) % zoom;
+
+ let translate = "translate";
+ if (brushSize % 2 === 0) {
+ translate += `(${gridedX - highlightBrushSize / 2}, ${gridedY - highlightBrushSize / 2})`;
+ }
+ else {
+ translate += `(${gridedX - highlightBrushSize / 2 + zoom / 2}, ${gridedY - highlightBrushSize / 2 + zoom / 2})`;
+ }
+ this.highlightElements.setAttribute("transform", `${translate} scale(${highlightBrushSize})`);
+ this.highlightElements.setAttribute("visibility", "visible");
+ }
+
+ hideSvgHighlight() {
+ this.highlightElements.setAttribute("visibility", "hidden");
+ }
+
+ updateSvgSize(width: number, height: number) {
+ this.svg.setAttribute("width", String(width));
+ this.svg.setAttribute("height", String(height));
+ }
+
+ public reparentSvgTo(newParentElement: HTMLDivElement): void {
+ if (!this.svg) {
+ return;
+ }
+
+ if (this.svg.parentElement) {
+ this.svg.remove();
+ }
+
+ newParentElement.appendChild(this.svg);
+ this.parentHtmlElement = newParentElement;
+ }
+
+ drawUVs(UVs: number[]) {
+ const textureSize = this.canvasManager.getTextureSize();
+ const pixelUvValue = { x: 1 / textureSize.x, y: 1 / textureSize.y };
+ const defaultStyle = {
+ pointerEvents: "none",
+ strokeWidth: 2
+ };
+
+ for (let i = 0; i < UVs.length; i += 12) {
+ const uvHighLight = document.createElementNS(kSvgNs, "rect");
+ Object.assign(uvHighLight.style, defaultStyle);
+ uvHighLight.setAttribute("stroke", "red");
+ uvHighLight.setAttribute("fill", "none");
+ uvHighLight.setAttribute("x", String(UVs[i]));
+ uvHighLight.setAttribute("y", "0.01");
+ uvHighLight.setAttribute("width", String(UVs[i] * pixelUvValue.x));
+ uvHighLight.setAttribute("height", String(UVs[i + 1] * pixelUvValue.y));
+ uvHighLight.setAttribute("vector-effect", "non-scaling-stroke");
+ }
+ }
+
+ // createRect() {
+
+ // }
+}
diff --git a/packages/editors/texture/src/three/GroupManager.ts b/packages/editors/texture/src/three/GroupManager.ts
new file mode 100644
index 0000000..04b9d20
--- /dev/null
+++ b/packages/editors/texture/src/three/GroupManager.ts
@@ -0,0 +1,190 @@
+// Import Third-party Dependencies
+import * as THREE from "three";
+
+// CONSTANTS
+const kPointTextureSize = 256;
+const kPointSize = 15;
+const kPivotColor = 0xff00ff;
+const kEdgeDefaultColor = 0x000000;
+const kEdgeSelectedColor = 0xff00ff;
+
+export interface GroupManagerOptions {
+ pos?: THREE.Vector3;
+ pivotPos?: THREE.Vector3;
+ size?: THREE.Vector3;
+ scale?: THREE.Vector3;
+ color?: THREE.Color;
+ name?: string;
+ texture?: THREE.Texture | null;
+}
+
+export default class GroupManager {
+ private group: THREE.Group;
+ private mesh: THREE.Mesh;
+ private pivotPoint: THREE.Points;
+ private edges: THREE.LineSegments;
+ private isSelected: boolean = false;
+
+ constructor(options: GroupManagerOptions = {}) {
+ const {
+ pos = new THREE.Vector3(0, 0, 0),
+ pivotPos = new THREE.Vector3(0, 0, 0),
+ size = new THREE.Vector3(1, 1, 1),
+ scale = new THREE.Vector3(1, 1, 1),
+ color = new THREE.Color(0xffffff),
+ name,
+ texture = null
+ } = options;
+
+ // Create the pivot group
+ this.group = new THREE.Group();
+ this.group.position.copy(pos);
+
+ // Create the mesh
+ const geometry = new THREE.BoxGeometry(...size);
+ const material = new THREE.MeshBasicMaterial({
+ color,
+ transparent: true,
+ alphaTest: 0.01,
+ side: THREE.DoubleSide,
+ map: texture
+ });
+ material.needsUpdate = true;
+
+ this.mesh = new THREE.Mesh(geometry, material);
+ this.mesh.position.copy(pivotPos);
+ this.mesh.scale.x = scale.x;
+ this.mesh.scale.y = scale.y;
+ this.mesh.scale.z = scale.z;
+ const meshName = name || `mesh_${this.group.uuid}`;
+ this.mesh.name = meshName;
+
+ // Create edges
+ const edgesGeo = new THREE.EdgesGeometry(geometry);
+ const edgesMat = new THREE.LineBasicMaterial({ color: kEdgeDefaultColor });
+ this.edges = new THREE.LineSegments(edgesGeo, edgesMat);
+ this.edges.name = "edges";
+ this.mesh.add(this.edges);
+
+ // Add mesh to group
+ this.group.add(this.mesh);
+
+ // Create pivot point
+ this.pivotPoint = this.createPivotPoint(pos);
+ this.group.add(this.pivotPoint);
+ }
+
+ private createPivotPoint(pos: THREE.Vector3): THREE.Points {
+ const pivotPointGeo = new THREE.BufferGeometry();
+ pivotPointGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0, 0, 0]), 3));
+
+ const pivotPointMat = new THREE.PointsMaterial({
+ color: kPivotColor,
+ size: kPointSize,
+ sizeAttenuation: false,
+ map: this.pointTexture(kPointTextureSize)
+ });
+ pivotPointMat.depthTest = false;
+ pivotPointMat.transparent = true;
+
+ const pivotPoint = new THREE.Points(pivotPointGeo, pivotPointMat);
+ pivotPoint.name = "pivot_visual";
+ pivotPoint.visible = false;
+ pivotPoint.renderOrder = 1;
+ pivotPoint.position.copy(pos);
+
+ return pivotPoint;
+ }
+
+ private pointTexture(size: number = 64): THREE.Texture {
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d")!;
+ ctx.beginPath();
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
+ ctx.fillStyle = "white";
+ ctx.fill();
+
+ return new THREE.CanvasTexture(canvas);
+ }
+
+ public getGroup(): THREE.Group {
+ return this.group;
+ }
+
+ public getMesh(): THREE.Mesh {
+ return this.mesh;
+ }
+
+ public select(): void {
+ if (this.isSelected) {
+ return;
+ }
+
+ this.isSelected = true;
+
+ if (this.edges.material instanceof THREE.LineBasicMaterial) {
+ this.edges.material.color.set(kEdgeSelectedColor);
+ }
+
+ this.pivotPoint.visible = true;
+ }
+
+ public deselect(): void {
+ if (!this.isSelected) {
+ return;
+ }
+
+ this.isSelected = false;
+
+ if (this.edges.material instanceof THREE.LineBasicMaterial) {
+ this.edges.material.color.set(kEdgeDefaultColor);
+ }
+
+ this.pivotPoint.visible = false;
+ }
+
+ public isSelectedState(): boolean {
+ return this.isSelected;
+ }
+
+ public setTexture(texture: THREE.Texture | null): void {
+ if (this.mesh.material instanceof THREE.MeshBasicMaterial) {
+ this.mesh.material.map = texture;
+ this.mesh.material.needsUpdate = true;
+ }
+ }
+
+ public dispose(): void {
+ // Dispose geometries
+ if (this.mesh.geometry) {
+ this.mesh.geometry.dispose();
+ }
+
+ // Dispose materials
+ if (this.mesh.material instanceof THREE.MeshBasicMaterial) {
+ this.mesh.material.dispose();
+ }
+
+ if (this.edges.material instanceof THREE.LineBasicMaterial) {
+ this.edges.material.dispose();
+ }
+
+ if (this.pivotPoint.material instanceof THREE.PointsMaterial) {
+ this.pivotPoint.material.dispose();
+ if (this.pivotPoint.material.map) {
+ this.pivotPoint.material.map.dispose();
+ }
+ }
+
+ // Remove from parent if attached
+ if (this.group.parent) {
+ this.group.parent.remove(this.group);
+ }
+ }
+
+ public getGroupUUID(): string {
+ return this.group.uuid;
+ }
+}
diff --git a/packages/editors/texture/src/three/ModelManager.ts b/packages/editors/texture/src/three/ModelManager.ts
new file mode 100644
index 0000000..c36c2cc
--- /dev/null
+++ b/packages/editors/texture/src/three/ModelManager.ts
@@ -0,0 +1,107 @@
+// Import Third-party Dependencies
+import * as THREE from "three";
+import { TransformControls } from "three/examples/jsm/Addons.js";
+
+// Import Internal Dependencies
+import GroupManager, { type GroupManagerOptions } from "./GroupManager.js";
+
+export interface ModelManagerOptions {
+ scene: THREE.Scene;
+ transformControl: TransformControls;
+}
+
+export default class ModelManager {
+ private scene: THREE.Scene;
+ private transformControl: TransformControls;
+ private groups: GroupManager[] = [];
+ private selectedGroup: GroupManager | null = null;
+ private meshToGroupMap: Map = new Map();
+
+ constructor(options: ModelManagerOptions) {
+ this.scene = options.scene;
+ this.transformControl = options.transformControl;
+ }
+
+ public addGroup(options?: GroupManagerOptions): GroupManager {
+ const group = new GroupManager(options);
+ this.groups.push(group);
+
+ // Map mesh to group for quick lookup
+ this.meshToGroupMap.set(group.getMesh(), group);
+
+ // Add group to scene
+ this.scene.add(group.getGroup());
+
+ return group;
+ }
+
+ public removeGroup(group: GroupManager): void {
+ const index = this.groups.indexOf(group);
+ if (index === -1) {
+ return;
+ }
+
+ // If this is the selected group, deselect it
+ if (this.selectedGroup === group) {
+ this.selectGroup(null);
+ }
+
+ // Remove from map
+ this.meshToGroupMap.delete(group.getMesh());
+
+ // Dispose resources
+ group.dispose();
+
+ // Remove from array
+ this.groups.splice(index, 1);
+ }
+
+ public selectGroup(group: GroupManager | null): void {
+ // Deselect previous group
+ if (this.selectedGroup && this.selectedGroup !== group) {
+ this.selectedGroup.deselect();
+ }
+
+ this.selectedGroup = group;
+
+ if (!group) {
+ this.transformControl.detach();
+
+ return;
+ }
+
+ group.select();
+ this.transformControl.attach(group.getGroup());
+ this.scene.add(this.transformControl.getHelper());
+ }
+
+ public getSelectedGroup(): GroupManager | null {
+ return this.selectedGroup;
+ }
+
+ public getGroups(): GroupManager[] {
+ return this.groups;
+ }
+
+ public getGroupByMesh(mesh: THREE.Mesh): GroupManager | undefined {
+ return this.meshToGroupMap.get(mesh);
+ }
+
+ public getGroupByUUID(uuid: string): GroupManager | undefined {
+ return this.groups.find((group) => group.getGroupUUID() === uuid);
+ }
+
+ public setTextureForAll(texture: THREE.Texture | null): void {
+ this.groups.forEach((group) => {
+ group.setTexture(texture);
+ });
+ }
+
+ public disposeAll(): void {
+ // Create a copy of the array since removeGroup modifies it
+ const groupsCopy = [...this.groups];
+ groupsCopy.forEach((group) => {
+ this.removeGroup(group);
+ });
+ }
+}
diff --git a/packages/editors/texture/src/three/ThreeSceneManager.ts b/packages/editors/texture/src/three/ThreeSceneManager.ts
new file mode 100644
index 0000000..b6ad35e
--- /dev/null
+++ b/packages/editors/texture/src/three/ThreeSceneManager.ts
@@ -0,0 +1,213 @@
+// Import Third-party Dependencies
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+import { TransformControls } from "three/examples/jsm/Addons.js";
+// import { ViewHelper } from 'three/addons/helpers/ViewHelper.js';
+import { Input } from "@jolly-pixel/engine";
+
+// Import Internal Dependencies
+import type CanvasManager from "../texture/CanvasManager.js";
+import ModelManager from "./ModelManager.js";
+import type GroupManager from "./GroupManager.js";
+
+export default class ThreeSceneManager {
+ private rootHTMLElement: HTMLDivElement;
+
+ private renderer: THREE.WebGLRenderer;
+ private input: Input;
+
+ private scene: THREE.Scene;
+ private camera: THREE.PerspectiveCamera;
+ private cameraRaycaster: THREE.Raycaster;
+
+ private orbitalControl: OrbitControls;
+ private transformControl: TransformControls;
+ private modelManager: ModelManager;
+
+ private isTransformControlsDragging: boolean = false;
+
+ private texture: THREE.CanvasTexture | null = null;
+
+ constructor(rootHTMLElement: HTMLDivElement) {
+ this.rootHTMLElement = rootHTMLElement;
+
+ this.renderer = new THREE.WebGLRenderer({
+ alpha: true,
+ antialias: true
+ });
+ this.renderer.sortObjects = true;
+
+ const bounding = this.rootHTMLElement.getBoundingClientRect();
+
+ this.scene = new THREE.Scene();
+ this.camera = new THREE.PerspectiveCamera(75, bounding.width / bounding.height, 0.1, 1000);
+ this.camera.position.z = 5;
+ this.camera.position.y = 1;
+ this.cameraRaycaster = new THREE.Raycaster();
+
+ this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
+
+ this.transformControl.addEventListener("dragging-changed", (event: any) => {
+ this.isTransformControlsDragging = event.value;
+ this.orbitalControl.enabled = !event.value;
+ });
+
+ this.orbitalControl = this.initOrbitControl();
+
+ this.modelManager = new ModelManager({
+ scene: this.scene,
+ transformControl: this.transformControl
+ });
+
+ this.input = new Input(this.renderer.domElement);
+ this.input.connect();
+ this.update();
+
+ this.scene.add(new THREE.GridHelper(10, 10));
+
+ this.renderer.setSize(bounding.width, bounding.height);
+ this.rootHTMLElement.appendChild(this.renderer.domElement);
+
+ this.renderer.setAnimationLoop(this.update.bind(this));
+ }
+
+ private initOrbitControl(): OrbitControls {
+ const orbitalControl = new OrbitControls(this.camera, this.renderer.domElement);
+ orbitalControl.mouseButtons = {
+ RIGHT: THREE.MOUSE.PAN,
+ MIDDLE: THREE.MOUSE.ROTATE
+ };
+ orbitalControl.enableZoom = true;
+
+ return orbitalControl;
+ }
+
+ private cameraRayCast() {
+ if (this.input.wasMouseButtonJustPressed("left")) {
+ if (this.isTransformControlsDragging) {
+ return;
+ }
+
+ this.cameraRaycaster.setFromCamera(
+ this.input.getMousePosition(),
+ this.camera
+ );
+
+ const meshes = this.modelManager.getGroups().map((group) => group.getMesh());
+ const cubeIntersects = this.cameraRaycaster.intersectObjects(meshes, false);
+
+ if (cubeIntersects.length > 0) {
+ const intersect = cubeIntersects[0];
+ const mesh = intersect.object as THREE.Mesh;
+ const group = this.modelManager.getGroupByMesh(mesh);
+
+ if (group) {
+ this.modelManager.selectGroup(group);
+ this.dispatchGroupSelected(group);
+ }
+
+ return;
+ }
+
+ this.modelManager.selectGroup(null);
+ this.dispatchGroupSelected(null);
+ }
+ }
+
+ public createCube(name: string = "Cube"): GroupManager {
+ const group = this.modelManager.addGroup({
+ texture: this.texture
+ });
+
+ this.dispatchGroupCreated(group, name);
+
+ return group;
+ }
+
+ private dispatchGroupCreated(group: any, name: string = "Cube"): void {
+ const event = new CustomEvent("groupCreated", {
+ detail: { group, name },
+ bubbles: true,
+ composed: true
+ });
+ this.rootHTMLElement.dispatchEvent(event);
+ }
+
+ private dispatchGroupSelected(group: any | null): void {
+ const event = new CustomEvent("groupSelected", {
+ detail: { group },
+ bubbles: true,
+ composed: true
+ });
+ this.rootHTMLElement.dispatchEvent(event);
+ }
+
+ public getModelManager(): ModelManager {
+ return this.modelManager;
+ }
+
+ public setControlsEnabled(enabled: boolean): void {
+ this.orbitalControl.enabled = enabled;
+ this.transformControl.enabled = enabled;
+ if (enabled) {
+ this.input.connect();
+ }
+ else {
+ this.input.disconnect();
+ }
+ }
+
+ onResize() {
+ const bounding = this.rootHTMLElement.getBoundingClientRect();
+ this.renderer.setSize(bounding.width, bounding.height);
+ this.camera.aspect = bounding.width / bounding.height;
+ this.camera.updateProjectionMatrix();
+ }
+
+ update() {
+ this.input.update();
+ this.cameraRayCast();
+ this.handleKeyboardControls();
+ this.orbitalControl.update();
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ private handleKeyboardControls(): void {
+ const moveDistance = 0.1;
+ const direction = new THREE.Vector3();
+
+ if (this.input.isKeyDown("W")) {
+ direction.z -= moveDistance;
+ }
+ if (this.input.isKeyDown("A")) {
+ direction.x -= moveDistance;
+ }
+ if (this.input.isKeyDown("S")) {
+ direction.z += moveDistance;
+ }
+ if (this.input.isKeyDown("D")) {
+ direction.x += moveDistance;
+ }
+ if (this.input.isKeyDown("Space")) {
+ direction.y += moveDistance;
+ }
+ if (this.input.isKeyDown("ShiftLeft")) {
+ direction.y -= moveDistance;
+ }
+
+ this.camera.position.add(direction);
+ this.orbitalControl.target.add(direction);
+ }
+
+ public setCanvasTexture(canvasManager: CanvasManager): void {
+ const textureCanvas = canvasManager.getTextureCanvas();
+ this.texture = new THREE.CanvasTexture(textureCanvas);
+ this.texture.magFilter = THREE.NearestFilter;
+ this.texture.minFilter = THREE.NearestFilter;
+ this.texture.needsUpdate = true;
+ this.texture.generateMipmaps = false;
+
+ this.modelManager.setTextureForAll(this.texture);
+ }
+}
diff --git a/packages/editors/texture/src/utils.ts b/packages/editors/texture/src/utils.ts
new file mode 100644
index 0000000..d90a7c4
--- /dev/null
+++ b/packages/editors/texture/src/utils.ts
@@ -0,0 +1,31 @@
+export function hexToRgb(hex: string): { r: number; g: number; b: number; } {
+ const bigint = parseInt(hex.slice(1), 16);
+
+ return {
+ r: (bigint >> 16) & 255,
+ g: (bigint >> 8) & 255,
+ b: bigint & 255
+ };
+}
+
+export function getColorAsRGBA(color: string): [number, number, number, number] {
+ const ctx = document.createElement("canvas").getContext("2d")!;
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, 1, 1);
+
+ // Need to force rgba format to simplify everything with colors
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+
+ return [r, g, b, a];
+}
+
+export function rgbToHex(r, g, b) {
+ // Vérifier que les valeurs sont entre 0 et 255
+ if ([r, g, b].some((val) => val < 0 || val > 255)) {
+ throw new Error("Les valeurs RGB doivent être comprises entre 0 et 255.");
+ }
+
+ const hexa = [r, g, b].map((val) => val.toString(16).padStart(2, "0"));
+
+ return `#${hexa[0]}${hexa[1]}${hexa[2]}`;
+}
diff --git a/packages/editors/texture/tsconfig.json b/packages/editors/texture/tsconfig.json
new file mode 100644
index 0000000..085a345
--- /dev/null
+++ b/packages/editors/texture/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ],
+ "references": [
+ {
+ "path": "../../engine"
+ }
+ ]
+}
diff --git a/packages/editors/texture/vite.config.ts b/packages/editors/texture/vite.config.ts
new file mode 100644
index 0000000..88d40b7
--- /dev/null
+++ b/packages/editors/texture/vite.config.ts
@@ -0,0 +1,8 @@
+// Import Third-party Dependencies
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ esbuild: {
+ target: "es2024"
+ }
+});
diff --git a/tsconfig.json b/tsconfig.json
index 4cb5b8c..048bc29 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -27,6 +27,9 @@
},
{
"path": "./packages/editors/model"
+ },
+ {
+ "path": "./packages/editors/texture"
}
]
}