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 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + 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 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + 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 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + 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` + +
+ + +
+ `; + } +} + +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
  • +
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ + +
+
+ + + +
+
+ + +
+ +
+
    +
  • +
  • 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" } ] }