From 2837cf672c4d65791354812cfe1eda892dbf7972 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 29 Apr 2026 01:00:52 +0200 Subject: [PATCH 01/10] feat: texture painter experiment with per-texture canvas editors (#87) - Extract buildMaterial to src/utils/materialBuilder.ts shared utility - Create TexturePainter view: sphere preview with all material controls - Per-texture CanvasEditor with limited 2-4 color palette per slot - Textures persisted to localStorage via storageSaveLocal - Remove overflow from PanelContainer except on mobile Co-Authored-By: Claude Sonnet 4.6 --- src/components/panels/PanelContainer.vue | 5 +- src/config/viewsMeta.json | 75 ++- src/utils/materialBuilder.ts | 127 ++++ .../MaterialsList/MaterialsList.vue | 89 +-- .../TexturePainter/TexturePainter.vue | 612 ++++++++++++++++++ .../Experiments/TexturePainter/config.ts | 85 +++ 6 files changed, 889 insertions(+), 104 deletions(-) create mode 100644 src/utils/materialBuilder.ts create mode 100644 src/views/Experiments/TexturePainter/TexturePainter.vue create mode 100644 src/views/Experiments/TexturePainter/config.ts diff --git a/src/components/panels/PanelContainer.vue b/src/components/panels/PanelContainer.vue index 2b4af5b9..ea23af93 100644 --- a/src/components/panels/PanelContainer.vue +++ b/src/components/panels/PanelContainer.vue @@ -59,11 +59,14 @@ defineProps<{ align-items: flex-start; z-index: calc(var(--z-overlay) + 1); pointer-events: none; - overflow-y: auto; > * { pointer-events: auto; } + + @media (max-width: 600px) { + overflow-y: auto; + } } .panel-container--right { diff --git a/src/config/viewsMeta.json b/src/config/viewsMeta.json index 7b07279a..d356802a 100644 --- a/src/config/viewsMeta.json +++ b/src/config/viewsMeta.json @@ -11,29 +11,45 @@ "Continuous World": { "description": "Procedurally generated infinite terrain with real-time chunk streaming." }, - "Draw Path": { "description": "Interactive path drawing tool with smooth curve generation." }, - "Earth Gazer": { "description": "Realistic 3D Earth globe with atmosphere and cloud layers." }, - "Earth View": { "description": "3D Earth view with satellite imagery and orbit controls." }, + "Draw Path": { + "description": "Interactive path drawing tool with smooth curve generation." + }, + "Earth Gazer": { + "description": "Realistic 3D Earth globe with atmosphere and cloud layers." + }, + "Earth View": { + "description": "3D Earth view with satellite imagery and orbit controls." + }, "Materials List": { "description": "Interactive showcase of all Three.js PBR material types with live property controls." }, "Model Animation": { "description": "Load and preview animated 3D models with playback controls." }, - "Moon Two": { "description": "3D Moon surface rendering with displacement mapping." }, - "Moon View": { "description": "Detailed 3D Moon view with realistic surface shading." }, + "Moon Two": { + "description": "3D Moon surface rendering with displacement mapping." + }, + "Moon View": { + "description": "Detailed 3D Moon view with realistic surface shading." + }, "Multiplayer P 2 P": { "description": "Peer-to-peer multiplayer demo using WebRTC data channels." }, "Pathfinder Threejs": { "description": "3D pathfinding visualisation with A* algorithm on a Three.js grid." }, - "Physic Ball": { "description": "Physics simulation with bouncing balls using Rapier.js." }, - "Physic Basic": { "description": "Basic rigid body physics demo with Rapier.js and Three.js." }, + "Physic Ball": { + "description": "Physics simulation with bouncing balls using Rapier.js." + }, + "Physic Basic": { + "description": "Basic rigid body physics demo with Rapier.js and Three.js." + }, "Physic Examples": { "description": "Collection of physics interaction examples: joints, colliders, forces." }, - "Physic Fluid": { "description": "Fluid simulation with particle dynamics and surface tension." }, + "Physic Fluid": { + "description": "Fluid simulation with particle dynamics and surface tension." + }, "Threejs Example Controller": { "description": "Character controller demo with third-person camera." }, @@ -43,12 +59,18 @@ "Timeline Game": { "description": "Game built entirely on the custom timeline animation system." }, - "Visualizer Main": { "description": "Music visualiser with frequency-reactive 3D geometry." }, - "Chick Run": { "description": "Endless runner game — dodge obstacles as a speedy chick." }, + "Visualizer Main": { + "description": "Music visualiser with frequency-reactive 3D geometry." + }, + "Chick Run": { + "description": "Endless runner game — dodge obstacles as a speedy chick." + }, "Forest Game": { "description": "3D forest exploration game with character movement and environment." }, - "Goomba Runner": { "description": "Side-scrolling platformer featuring Goomba-style enemies." }, + "Goomba Runner": { + "description": "Side-scrolling platformer featuring Goomba-style enemies." + }, "Maze Game": { "description": "3D maze navigation game with procedural maze generation and pathfinding enemies." }, @@ -64,8 +86,12 @@ "Cube Matrix Threejs": { "description": "Three.js GPU-accelerated cube matrix with instanced rendering." }, - "Cube Sequences": { "description": "Sequenced cube animations driven by a custom timeline." }, - "Cube Shift": { "description": "Cubes shifting position in rhythmic wave patterns." }, + "Cube Sequences": { + "description": "Sequenced cube animations driven by a custom timeline." + }, + "Cube Shift": { + "description": "Cubes shifting position in rhythmic wave patterns." + }, "Falling View": { "description": "Particles falling with physics-inspired motion and colour gradients." }, @@ -75,13 +101,21 @@ "Metal Cubes": { "description": "Reflective metallic cubes with PBR shading and environment maps." }, - "Metal Cubes 2": { "description": "Enhanced metallic cube array with dynamic lighting." }, - "Simplex Cached": { "description": "Simplex noise terrain with chunk caching for performance." }, - "Simplex Worker": { "description": "Simplex noise generation offloaded to a Web Worker." }, + "Metal Cubes 2": { + "description": "Enhanced metallic cube array with dynamic lighting." + }, + "Simplex Cached": { + "description": "Simplex noise terrain with chunk caching for performance." + }, + "Simplex Worker": { + "description": "Simplex noise generation offloaded to a Web Worker." + }, "Canvas Texture Editor": { "description": "Browser-based canvas texture painting and editing tool." }, - "Mixamo Playground": { "description": "Test and preview Mixamo character animations in 3D." }, + "Mixamo Playground": { + "description": "Test and preview Mixamo character animations in 3D." + }, "Model Loader": { "description": "Drag-and-drop 3D model loader supporting GLB, GLTF, FBX formats." }, @@ -91,5 +125,10 @@ "Scene Editor": { "description": "Visual 3D scene composition tool with object placement and lighting." }, - "Tools Test": { "description": "Internal tooling test page for development utilities." } + "Tools Test": { + "description": "Internal tooling test page for development utilities." + }, + "Texture Painter": { + "description": "Paint custom textures on a 3D sphere and preview them live across different material types." + } } diff --git a/src/utils/materialBuilder.ts b/src/utils/materialBuilder.ts new file mode 100644 index 00000000..5acf6fd2 --- /dev/null +++ b/src/utils/materialBuilder.ts @@ -0,0 +1,127 @@ +import * as THREE from 'three' +import type { + MaterialTypeName, + MapToggleKey, + MaterialsListConfig +} from '@/views/Experiments/MaterialsList/types' +import { + EMISSIVE_COLOR, + EMISSIVE_INTENSITY, + CLEARCOAT_ROUGHNESS_VALUE, + ENV_MAP_INTENSITY, + ENV_MAP_REFLECTIVITY, + NORMAL_STRENGTH, + DISPLACEMENT_SCALE, + MATERIAL_COLOR +} from '@/views/Experiments/MaterialsList/materialsListConfig' + +export interface BuildMaterialScene { + textures: Record + envMap: THREE.Texture | null +} + +const MATERIAL_CONSTRUCTORS: Record THREE.Material> = { + MeshBasicMaterial: THREE.MeshBasicMaterial, + MeshLambertMaterial: THREE.MeshLambertMaterial, + MeshPhongMaterial: THREE.MeshPhongMaterial, + MeshStandardMaterial: THREE.MeshStandardMaterial, + MeshPhysicalMaterial: THREE.MeshPhysicalMaterial, + MeshToonMaterial: THREE.MeshToonMaterial, + MeshNormalMaterial: THREE.MeshNormalMaterial, + MeshDepthMaterial: THREE.MeshDepthMaterial +} + +const applyTextureParameters = ( + parameters: Record, + hasFeature: (key: MapToggleKey) => boolean, + textures: Record +): void => { + if (hasFeature('diffuse')) parameters.map = textures.diffuse + if (hasFeature('normal')) { + parameters.normalMap = textures.normal + parameters.normalScale = new THREE.Vector2(NORMAL_STRENGTH, NORMAL_STRENGTH) + } + if (hasFeature('roughness')) parameters.roughnessMap = textures.roughness + if (hasFeature('metalness')) parameters.metalnessMap = textures.roughness + if (hasFeature('ao')) { + parameters.aoMap = textures.ao + parameters.aoMapIntensity = 1 + } + if (hasFeature('displacement')) { + parameters.displacementMap = textures.displacement + parameters.displacementScale = DISPLACEMENT_SCALE + } +} + +interface ApplyContext { + typeName: MaterialTypeName + hasFeature: (key: MapToggleKey) => boolean + textures: Record + configValues: MaterialsListConfig + envMap: THREE.Texture | null +} + +const applyMaterialTypeParameters = ( + parameters: Record, + { typeName, hasFeature, textures, configValues, envMap }: ApplyContext +): void => { + if (hasFeature('emissive') && typeName !== 'MeshBasicMaterial') { + parameters.emissiveMap = textures.emissive + parameters.emissive = new THREE.Color(EMISSIVE_COLOR) + parameters.emissiveIntensity = EMISSIVE_INTENSITY + } + if (hasFeature('envMap') && envMap) { + const isPbr = ['MeshStandardMaterial', 'MeshPhysicalMaterial'].includes(typeName) + parameters.envMap = envMap + if (isPbr) { + parameters.envMapIntensity = ENV_MAP_INTENSITY + } else { + parameters.combine = THREE.AddOperation + parameters.reflectivity = ENV_MAP_REFLECTIVITY + } + } + if (['MeshStandardMaterial', 'MeshPhysicalMaterial'].includes(typeName)) { + parameters.roughness = configValues.properties.roughness + parameters.metalness = configValues.properties.metalness + } + if (typeName === 'MeshPhysicalMaterial') { + parameters.clearcoat = configValues.properties.clearcoat + parameters.clearcoatRoughness = CLEARCOAT_ROUGHNESS_VALUE + parameters.transmission = configValues.properties.transmission + } + if (typeName === 'MeshPhongMaterial') parameters.shininess = configValues.properties.shininess + if (typeName === 'MeshNormalMaterial') + parameters.flatShading = configValues.properties.flatShading +} + +/** + * Build a Three.js material from type, enabled maps, config values, and scene resources. + * @param typeName - Material type identifier + * @param supported - Map keys this material type supports + * @param maps - Which maps are currently enabled + * @param configValues - Property values (roughness, metalness, etc.) + * @param scene - Scene resources containing textures and envMap + * @returns Constructed Three.js material + */ +export const buildMaterial = ( + typeName: MaterialTypeName, + supported: MapToggleKey[], + maps: Record, + configValues: MaterialsListConfig, + scene: BuildMaterialScene +): THREE.Material => { + const { textures, envMap } = scene + const hasFeature = (key: MapToggleKey): boolean => supported.includes(key) && maps[key] + const parameters: Record = {} + + if (!['MeshNormalMaterial', 'MeshDepthMaterial'].includes(typeName)) { + parameters.color = MATERIAL_COLOR + } + applyTextureParameters(parameters, hasFeature, textures) + applyMaterialTypeParameters(parameters, { typeName, hasFeature, textures, configValues, envMap }) + + const Constructor = MATERIAL_CONSTRUCTORS[typeName] + const material = new Constructor(parameters as never) + ;(material as THREE.MeshBasicMaterial).wireframe = configValues.properties.wireframe + return material +} diff --git a/src/views/Experiments/MaterialsList/MaterialsList.vue b/src/views/Experiments/MaterialsList/MaterialsList.vue index ffdda1a1..5e0c6c58 100644 --- a/src/views/Experiments/MaterialsList/MaterialsList.vue +++ b/src/views/Experiments/MaterialsList/MaterialsList.vue @@ -4,13 +4,14 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { ref, onMounted, onBeforeUnmount } from 'vue' import { useRoute } from 'vue-router' import { stats } from '@/utils/stats' +import { buildMaterial } from '@/utils/materialBuilder' import { getTools, createTextSprite } from '@webgamekit/threejs' import { createTimelineManager, animateTimeline } from '@webgamekit/animation' import { useDebugSceneStore } from '@/stores/debugScene' import { registerViewConfig, unregisterViewConfig, createReactiveConfig } from '@/stores/viewConfig' import { useViewPanelsStore } from '@/stores/viewPanels' import brickTextureUrl from '@/assets/images/textures/brick.jpg' -import type { MaterialTypeName, MapToggleKey, MaterialsListConfig } from './types' +import type { MaterialTypeName, MaterialsListConfig } from './types' import { MATERIAL_TYPES, MAIN_MATERIAL_TYPES, @@ -22,16 +23,9 @@ import { TEXT_COLOR_PROPERTIES, TEXT_COLOR_VALUE, PROCEDURAL_TEXTURE_SIZE, - DISPLACEMENT_SCALE, - NORMAL_STRENGTH, LIGHT_ORBIT_SPEED, LIGHT_ORBIT_RADIUS, LIGHT_Z_POSITION, - EMISSIVE_COLOR, - EMISSIVE_INTENSITY, - CLEARCOAT_ROUGHNESS_VALUE, - ENV_MAP_INTENSITY, - ENV_MAP_REFLECTIVITY, ENV_GROUND_SIZE, ENV_GROUND_Y, ENV_LIGHT_INTENSITY, @@ -45,7 +39,6 @@ import { LIGHT_INTENSITY, HEMISPHERE_SKY, HEMISPHERE_GROUND, - MATERIAL_COLOR, ENV_LIGHT_COLOR, ORTHO_NEAR_PLANE, ORTHO_FAR_PLANE, @@ -256,88 +249,14 @@ const createEnvironmentMap = (rendererInstance: THREE.WebGLRenderer): THREE.Text return renderTarget.texture } -const buildMaterial = ( - typeName: MaterialTypeName, - supported: MapToggleKey[], - maps: Record, - configValues: MaterialsListConfig -): THREE.Material => { - const parameters: Record = {} - const hasFeature = (key: MapToggleKey): boolean => supported.includes(key) && maps[key] - - if (!['MeshNormalMaterial', 'MeshDepthMaterial'].includes(typeName)) { - parameters.color = MATERIAL_COLOR - } - if (hasFeature('diffuse')) parameters.map = textures.diffuse - if (hasFeature('normal')) { - parameters.normalMap = textures.normal - parameters.normalScale = new THREE.Vector2(NORMAL_STRENGTH, NORMAL_STRENGTH) - } - if (hasFeature('roughness')) parameters.roughnessMap = textures.roughness - if (hasFeature('metalness')) parameters.metalnessMap = textures.roughness - if (hasFeature('ao')) { - parameters.aoMap = textures.ao - parameters.aoMapIntensity = 1 - } - if (hasFeature('displacement')) { - parameters.displacementMap = textures.displacement - parameters.displacementScale = DISPLACEMENT_SCALE - } - if (hasFeature('emissive') && typeName !== 'MeshBasicMaterial') { - parameters.emissiveMap = textures.emissive - parameters.emissive = new THREE.Color(EMISSIVE_COLOR) - parameters.emissiveIntensity = EMISSIVE_INTENSITY - } - if (hasFeature('envMap') && envMap) { - const isPbr = ['MeshStandardMaterial', 'MeshPhysicalMaterial'].includes(typeName) - parameters.envMap = envMap - if (isPbr) { - parameters.envMapIntensity = ENV_MAP_INTENSITY - } else { - parameters.combine = THREE.AddOperation - parameters.reflectivity = ENV_MAP_REFLECTIVITY - } - } - if (['MeshStandardMaterial', 'MeshPhysicalMaterial'].includes(typeName)) { - parameters.roughness = configValues.properties.roughness - parameters.metalness = configValues.properties.metalness - } - if (typeName === 'MeshPhysicalMaterial') { - parameters.clearcoat = configValues.properties.clearcoat - parameters.clearcoatRoughness = CLEARCOAT_ROUGHNESS_VALUE - parameters.transmission = configValues.properties.transmission - } - if (typeName === 'MeshPhongMaterial') { - parameters.shininess = configValues.properties.shininess - } - if (typeName === 'MeshNormalMaterial') { - parameters.flatShading = configValues.properties.flatShading - } - - const materialConstructors: Record THREE.Material> = { - MeshBasicMaterial: THREE.MeshBasicMaterial, - MeshLambertMaterial: THREE.MeshLambertMaterial, - MeshPhongMaterial: THREE.MeshPhongMaterial, - MeshStandardMaterial: THREE.MeshStandardMaterial, - MeshPhysicalMaterial: THREE.MeshPhysicalMaterial, - MeshToonMaterial: THREE.MeshToonMaterial, - MeshNormalMaterial: THREE.MeshNormalMaterial, - MeshDepthMaterial: THREE.MeshDepthMaterial - } - - const Constructor = materialConstructors[typeName] - const material = new Constructor(parameters as never) - ;(material as THREE.MeshBasicMaterial).wireframe = configValues.properties.wireframe - return material -} - const buildShowcaseMaterial = (): THREE.Material => { const typeName = MATERIAL_TYPES[currentMaterialIndex.value] return buildMaterial( typeName, MATERIAL_FEATURES[typeName], getEnabledMaps(reactiveConfig.value), - reactiveConfig.value + reactiveConfig.value, + { textures, envMap } ) } diff --git a/src/views/Experiments/TexturePainter/TexturePainter.vue b/src/views/Experiments/TexturePainter/TexturePainter.vue new file mode 100644 index 00000000..1f113a9b --- /dev/null +++ b/src/views/Experiments/TexturePainter/TexturePainter.vue @@ -0,0 +1,612 @@ + + + + + diff --git a/src/views/Experiments/TexturePainter/config.ts b/src/views/Experiments/TexturePainter/config.ts new file mode 100644 index 00000000..6b0c37b5 --- /dev/null +++ b/src/views/Experiments/TexturePainter/config.ts @@ -0,0 +1,85 @@ +export { + MATERIAL_TYPES, + MAIN_MATERIAL_TYPES, + MATERIAL_LABELS, + MATERIAL_FEATURES, + DEFAULT_CONFIG, + CONFIG_SCHEMA, + getEnabledMaps, + SPHERE_SEGMENT_COUNT, + PROCEDURAL_TEXTURE_SIZE, + BRICK_WIDTH, + BRICK_HEIGHT, + MORTAR_SIZE, + MORTAR_EDGE_OFFSET, + SCENE_BG_COLOR, + LIGHT_INTENSITY, + AMBIENT_LIGHT_INTENSITY, + HEMISPHERE_SKY, + HEMISPHERE_GROUND, + LIGHT_ORBIT_RADIUS, + LIGHT_Z_POSITION, + ENV_SKY_COLOR, + ENV_GROUND_COLOR, + ENV_GROUND_SIZE, + ENV_GROUND_Y, + ENV_LIGHT_INTENSITY, + ENV_LIGHT_POSITION, + ENV_LIGHT_Y, + ENV_AMBIENT_COLOR, + ENV_AMBIENT_INTENSITY, + ENV_LIGHT_COLOR +} from '@/views/Experiments/MaterialsList/materialsListConfig' + +export type { MaterialTypeName, MaterialsListConfig } from '@/views/Experiments/MaterialsList/types' + +export const PAINTER_SPHERE_RADIUS = 2.5 +export const PAINTER_SPHERE_SEGMENTS = 64 +export const PAINTER_CANVAS_SIZE = 512 +export const PAINTER_LIGHT_ORBIT_SPEED = 0.3 +export const PAINTER_TARGET_FPS = 60 +export const PAINTER_ORTHO_FRUSTUM = 5.0 +export const PAINTER_ORTHO_NEAR = 0.1 +export const PAINTER_ORTHO_FAR = 100 +export const PAINTER_ORTHO_DISTANCE = 20 +export const PAINTER_DRAG_SENSITIVITY = 0.008 + +export const STORAGE_PREFIX = 'texture-painter' + +export type TextureSlotKey = 'diffuse' | 'normal' | 'roughness' | 'ao' | 'displacement' | 'emissive' + +export const TEXTURE_SLOTS: TextureSlotKey[] = [ + 'diffuse', + 'normal', + 'roughness', + 'ao', + 'displacement', + 'emissive' +] + +export const TEXTURE_SLOT_LABELS: Record = { + diffuse: 'Diffuse', + normal: 'Normal', + roughness: 'Roughness', + ao: 'Ambient Occlusion', + displacement: 'Displacement', + emissive: 'Emissive' +} + +export const TEXTURE_SLOT_PALETTE: Record = { + diffuse: ['#cc8866', '#884433', '#ffffff', '#000000'], + normal: ['#8080ff', '#404080', '#ffffff'], + roughness: ['#ffffff', '#000000', '#888888'], + ao: ['#ffffff', '#000000', 'rgba(0,0,0,0.4)'], + displacement: ['#cccccc', '#000000', '#ffffff'], + emissive: ['#ff6600', '#000000', '#ff3300'] +} + +export const TEXTURE_SLOT_DEFAULT_COLOR: Record = { + diffuse: '#cc8866', + normal: '#8080ff', + roughness: '#ffffff', + ao: '#ffffff', + displacement: '#cccccc', + emissive: '#ff6600' +} From b78372dccfcdca90401610f89a456d705293b886 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 29 Apr 2026 08:54:02 +0200 Subject: [PATCH 02/10] fix: make texture editors accessible in config panel - Add overflow-y: auto to .sheet-content so panels scroll on desktop - Move maps toggles to Scene panel via registerSceneConfig, reducing Config panel height so texture editors are visible without scrolling Co-Authored-By: Claude Sonnet 4.6 --- src/components/panels/PanelContainer.vue | 1 + .../TexturePainter/TexturePainter.vue | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/panels/PanelContainer.vue b/src/components/panels/PanelContainer.vue index ea23af93..1b33b172 100644 --- a/src/components/panels/PanelContainer.vue +++ b/src/components/panels/PanelContainer.vue @@ -84,6 +84,7 @@ defineProps<{ background-color: var(--color-background); padding: var(--spacing-2); overflow-x: hidden; + overflow-y: auto; max-height: 100%; display: flex; flex-direction: column; diff --git a/src/views/Experiments/TexturePainter/TexturePainter.vue b/src/views/Experiments/TexturePainter/TexturePainter.vue index 1f113a9b..ff663b7e 100644 --- a/src/views/Experiments/TexturePainter/TexturePainter.vue +++ b/src/views/Experiments/TexturePainter/TexturePainter.vue @@ -8,6 +8,7 @@ import { createTimelineManager, animateTimeline } from '@webgamekit/animation' import { storageSaveLocal, storageLoadLocal } from '@webgamekit/canvas-editor' import { CanvasEditor } from '@/components/CanvasEditor' import { registerViewConfig, unregisterViewConfig, createReactiveConfig } from '@/stores/viewConfig' +import { registerSceneConfig, unregisterSceneConfig } from '@/stores/sceneConfig' import { useViewPanelsStore } from '@/stores/viewPanels' import { buildMaterial } from '@/utils/materialBuilder' import type { CanvasEditorToolButton } from '@/components/CanvasEditor' @@ -91,7 +92,25 @@ const configControls = { component: 'ButtonSelector' as const, options: MAIN_MATERIAL_TYPES.map((t) => ({ value: t, label: MATERIAL_LABELS[t] })) }, - ...CONFIG_SCHEMA + properties: CONFIG_SCHEMA.properties +} + +const mapsSceneRegistration = { + schema: { maps: CONFIG_SCHEMA.maps }, + getValue: (path: string): unknown => + path + .split('.') + .reduce( + (object, key) => (object as Record)?.[key], + reactiveConfig.value + ), + updateValue: (path: string, value: unknown): void => { + const [group, key] = path.split('.') + if (group === 'maps' && key) { + ;(reactiveConfig.value.maps as Record)[key] = value + rebuildMaterial() + } + } } const EDITOR_TOOLS: readonly CanvasEditorToolButton[] = [ @@ -465,8 +484,9 @@ const init = async (canvasReference: HTMLCanvasElement): Promise => { } onMounted(async () => { - setViewPanels({ showConfig: true }) + setViewPanels({ showConfig: true, showScene: true }) registerViewConfig(route.name as string, reactiveConfig as never, configControls, rebuildMaterial) + registerSceneConfig(mapsSceneRegistration) if (canvas.value) await init(canvas.value) window.addEventListener('resize', handleResize) }) @@ -474,6 +494,7 @@ onMounted(async () => { onBeforeUnmount(() => { clearViewPanels() unregisterViewConfig(route.name as string) + unregisterSceneConfig() window.removeEventListener('resize', handleResize) if (canvasElement) { canvasElement.removeEventListener('mousedown', handleMouseDown) From ce4c3e76a1116402bdbc9de0f127e625c63b8f1e Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 29 Apr 2026 09:27:45 +0200 Subject: [PATCH 03/10] feat: paint directly on sphere via UV raycasting - Replace panel CanvasEditor with direct sphere painting - Raycast mouse position to sphere UV coordinates, draw strokes on the corresponding offscreen canvas, set needsUpdate on the texture - Config panel: Mode toggle (Paint/Rotate), texture slot selector, colour picker, brush size slider - In Paint mode: left-click drag on sphere paints; cursor is crosshair - In Rotate mode: left-click drag on sphere rotates; cursor is grab Co-Authored-By: Claude Sonnet 4.6 --- .../TexturePainter/TexturePainter.vue | 368 ++++++------------ .../Experiments/TexturePainter/config.ts | 1 - 2 files changed, 119 insertions(+), 250 deletions(-) diff --git a/src/views/Experiments/TexturePainter/TexturePainter.vue b/src/views/Experiments/TexturePainter/TexturePainter.vue index ff663b7e..022d5da7 100644 --- a/src/views/Experiments/TexturePainter/TexturePainter.vue +++ b/src/views/Experiments/TexturePainter/TexturePainter.vue @@ -6,12 +6,10 @@ import { useRoute } from 'vue-router' import { getTools } from '@webgamekit/threejs' import { createTimelineManager, animateTimeline } from '@webgamekit/animation' import { storageSaveLocal, storageLoadLocal } from '@webgamekit/canvas-editor' -import { CanvasEditor } from '@/components/CanvasEditor' import { registerViewConfig, unregisterViewConfig, createReactiveConfig } from '@/stores/viewConfig' import { registerSceneConfig, unregisterSceneConfig } from '@/stores/sceneConfig' import { useViewPanelsStore } from '@/stores/viewPanels' import { buildMaterial } from '@/utils/materialBuilder' -import type { CanvasEditorToolButton } from '@/components/CanvasEditor' import { MAIN_MATERIAL_TYPES, MATERIAL_LABELS, @@ -23,7 +21,6 @@ import { BRICK_WIDTH, BRICK_HEIGHT, MORTAR_SIZE, - MORTAR_EDGE_OFFSET, SCENE_BG_COLOR, LIGHT_INTENSITY, AMBIENT_LIGHT_INTENSITY, @@ -48,12 +45,10 @@ import { PAINTER_ORTHO_NEAR, PAINTER_ORTHO_FAR, PAINTER_ORTHO_DISTANCE, - PAINTER_DRAG_SENSITIVITY, PAINTER_CANVAS_SIZE, STORAGE_PREFIX, TEXTURE_SLOTS, TEXTURE_SLOT_LABELS, - TEXTURE_SLOT_PALETTE, TEXTURE_SLOT_DEFAULT_COLOR } from './config' import type { MaterialTypeName, MaterialsListConfig, TextureSlotKey } from './config' @@ -68,8 +63,10 @@ let orbitControls: OrbitControls | null = null let orthoCamera: THREE.OrthographicCamera | null = null let rendererReference: THREE.WebGLRenderer | null = null let canvasElement: HTMLCanvasElement | null = null -let isDragging = false -let hasDragged = false + +type InteractionMode = 'paint' | 'rotate' +let interactionMode: InteractionMode = 'none' as InteractionMode +let lastPaintUv: THREE.Vector2 | null = null let dragLastX = 0 let dragLastY = 0 @@ -77,21 +74,43 @@ const textures: Record = {} const offscreenCanvases: Record = {} const reactiveConfig = createReactiveConfig< - MaterialsListConfig & { materialType: MaterialTypeName } + MaterialsListConfig & { + materialType: MaterialTypeName + activeSlot: TextureSlotKey + brushColor: string + brushSize: number + mode: InteractionMode + } >({ materialType: 'MeshStandardMaterial', + activeSlot: 'diffuse', + brushColor: TEXTURE_SLOT_DEFAULT_COLOR.diffuse, + brushSize: 20, + mode: 'paint', ...DEFAULT_CONFIG }) -const activeSlot = ref('diffuse') -const brushColors = ref>({ ...TEXTURE_SLOT_DEFAULT_COLOR }) - const configControls = { + mode: { + label: 'Mode', + component: 'ButtonSelector' as const, + options: [ + { value: 'paint', label: 'Paint' }, + { value: 'rotate', label: 'Rotate' } + ] + }, materialType: { label: 'Material', component: 'ButtonSelector' as const, options: MAIN_MATERIAL_TYPES.map((t) => ({ value: t, label: MATERIAL_LABELS[t] })) }, + activeSlot: { + label: 'Paint on', + component: 'ButtonSelector' as const, + options: TEXTURE_SLOTS.map((s) => ({ value: s, label: TEXTURE_SLOT_LABELS[s] })) + }, + brushColor: { color: true, label: 'Color' }, + brushSize: { min: 1, max: 80, step: 1, label: 'Brush Size' }, properties: CONFIG_SCHEMA.properties } @@ -113,16 +132,6 @@ const mapsSceneRegistration = { } } -const EDITOR_TOOLS: readonly CanvasEditorToolButton[] = [ - 'brush', - 'eraser', - 'fill', - 'size', - 'undo', - 'redo', - 'clear' -] - const storageKey = (slot: TextureSlotKey): string => `${STORAGE_PREFIX}-${slot}` const createOffscreenCanvas = ( @@ -156,83 +165,25 @@ const drawBrickGrid = ( ) } -const drawDefaultDiffuse = (ctx: CanvasRenderingContext2D, size: number): void => { - drawBrickGrid(ctx, size, '#cc8866', '#884433') -} - -const drawDefaultNormal = (ctx: CanvasRenderingContext2D, size: number): void => { - const imageData = ctx.createImageData(size, size) - const neutralBlue = 255 - const neutralGray = 128 - Array.from({ length: size * size }, (_, pixelIndex) => { - const x = pixelIndex % size - const y = Math.floor(pixelIndex / size) - const offset = y % (BRICK_HEIGHT * 2) < BRICK_HEIGHT ? 0 : BRICK_WIDTH / 2 - const brickX = (x + offset) % BRICK_WIDTH - const brickY = y % BRICK_HEIGHT - const isMortar = brickX < MORTAR_SIZE || brickY < MORTAR_SIZE - const dataIndex = pixelIndex * 4 - imageData.data[dataIndex] = isMortar ? neutralGray : neutralGray - imageData.data[dataIndex + 1] = isMortar ? neutralGray : neutralGray - imageData.data[dataIndex + 2] = neutralBlue - imageData.data[dataIndex + 3] = neutralBlue - return null - }) - ctx.putImageData(imageData, 0, 0) -} - -const drawDefaultRoughness = (ctx: CanvasRenderingContext2D, size: number): void => { - drawBrickGrid(ctx, size, '#ffffff', '#999999') -} - -const drawDefaultAo = (ctx: CanvasRenderingContext2D, size: number): void => { - ctx.fillStyle = '#ffffff' - ctx.fillRect(0, 0, size, size) - ctx.fillStyle = 'rgba(0,0,0,0.4)' - Array.from({ length: Math.ceil(size / BRICK_HEIGHT) }, (_, row) => - Array.from({ length: Math.ceil(size / BRICK_WIDTH) + 1 }, (__, col) => { - const offset = row % 2 === 0 ? 0 : BRICK_WIDTH / 2 - const brickX = col * BRICK_WIDTH - offset - ctx.fillRect(brickX, row * BRICK_HEIGHT, MORTAR_SIZE + 2, BRICK_HEIGHT) - ctx.fillRect(brickX, row * BRICK_HEIGHT, BRICK_WIDTH, MORTAR_SIZE + 2) - return null - }) - ) -} - -const drawDefaultDisplacement = (ctx: CanvasRenderingContext2D, size: number): void => { - ctx.fillStyle = '#000000' - ctx.fillRect(0, 0, size, size) - ctx.fillStyle = '#cccccc' - Array.from({ length: Math.ceil(size / BRICK_HEIGHT) }, (_, row) => - Array.from({ length: Math.ceil(size / BRICK_WIDTH) + 1 }, (__, col) => { - const offset = row % 2 === 0 ? 0 : BRICK_WIDTH / 2 - const brickX = col * BRICK_WIDTH - offset - ctx.fillRect( - brickX + MORTAR_SIZE, - row * BRICK_HEIGHT + MORTAR_SIZE, - BRICK_WIDTH - MORTAR_SIZE * 2, - BRICK_HEIGHT - MORTAR_SIZE * 2 - ) - return null - }) - ) -} - -const drawDefaultEmissive = (ctx: CanvasRenderingContext2D, size: number): void => { - drawBrickGrid(ctx, size, '#ff6600', '#000000') -} - const DEFAULT_DRAW_FUNCTIONS: Record< TextureSlotKey, (ctx: CanvasRenderingContext2D, size: number) => void > = { - diffuse: drawDefaultDiffuse, - normal: drawDefaultNormal, - roughness: drawDefaultRoughness, - ao: drawDefaultAo, - displacement: drawDefaultDisplacement, - emissive: drawDefaultEmissive + diffuse: (ctx, size) => drawBrickGrid(ctx, size, '#cc8866', '#884433'), + normal: (ctx, size) => { + ctx.fillStyle = '#8080ff' + ctx.fillRect(0, 0, size, size) + }, + roughness: (ctx, size) => drawBrickGrid(ctx, size, '#ffffff', '#999999'), + ao: (ctx, size) => { + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, size, size) + }, + displacement: (ctx, size) => { + ctx.fillStyle = '#000000' + ctx.fillRect(0, 0, size, size) + }, + emissive: (ctx, size) => drawBrickGrid(ctx, size, '#ff6600', '#000000') } const applyDataUrlToCanvas = (offscreen: HTMLCanvasElement, dataUrl: string): Promise => @@ -252,9 +203,7 @@ const initTextures = async (): Promise => { TEXTURE_SLOTS.map(async (slot) => { const saved = storageLoadLocal(storageKey(slot)) const offscreen = createOffscreenCanvas(DEFAULT_DRAW_FUNCTIONS[slot]) - if (saved?.dataUrl) { - await applyDataUrlToCanvas(offscreen, saved.dataUrl) - } + if (saved?.dataUrl) await applyDataUrlToCanvas(offscreen, saved.dataUrl) offscreenCanvases[slot] = offscreen const texture = new THREE.CanvasTexture(offscreen) texture.wrapS = THREE.RepeatWrapping @@ -286,16 +235,14 @@ const createEnvironmentMap = (renderer: THREE.WebGLRenderer): THREE.Texture => { return renderTarget.texture } -const buildSphereMaterial = (): THREE.Material => { - const typeName = reactiveConfig.value.materialType - return buildMaterial( - typeName, - MATERIAL_FEATURES[typeName], +const buildSphereMaterial = (): THREE.Material => + buildMaterial( + reactiveConfig.value.materialType, + MATERIAL_FEATURES[reactiveConfig.value.materialType], getEnabledMaps(reactiveConfig.value), reactiveConfig.value, { textures: textures as Record, envMap } ) -} const rebuildMaterial = (): void => { if (!sphere) return @@ -304,34 +251,64 @@ const rebuildMaterial = (): void => { old.dispose() } -const handleTextureChange = (slot: TextureSlotKey, dataUrl: string): void => { - storageSaveLocal(storageKey(slot), dataUrl) +const paintStroke = (fromUv: THREE.Vector2 | null, toUv: THREE.Vector2): void => { + const slot = reactiveConfig.value.activeSlot const offscreen = offscreenCanvases[slot] if (!offscreen) return - const img = new Image() - img.onload = () => { - const ctx = offscreen.getContext('2d')! - ctx.clearRect(0, 0, offscreen.width, offscreen.height) - ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height) - if (textures[slot]) textures[slot].needsUpdate = true + const ctx = offscreen.getContext('2d')! + const size = offscreen.width + const toX = toUv.x * size + const toY = (1 - toUv.y) * size + const radius = reactiveConfig.value.brushSize / 2 + + ctx.fillStyle = reactiveConfig.value.brushColor + ctx.strokeStyle = reactiveConfig.value.brushColor + ctx.lineWidth = reactiveConfig.value.brushSize + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + ctx.beginPath() + if (fromUv) { + ctx.moveTo(fromUv.x * size, (1 - fromUv.y) * size) + ctx.lineTo(toX, toY) + ctx.stroke() } - img.src = dataUrl + ctx.beginPath() + ctx.arc(toX, toY, radius, 0, Math.PI * 2) + ctx.fill() + + textures[slot].needsUpdate = true + storageSaveLocal(storageKey(slot), offscreen.toDataURL()) } -const getNdcFromEvent = (event: MouseEvent, rect: DOMRect): THREE.Vector2 => - new THREE.Vector2( +const getIntersection = (event: MouseEvent): THREE.Intersection | null => { + if (!orthoCamera || !sphere || !canvasElement) return null + const rect = canvasElement.getBoundingClientRect() + const ndc = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ) + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(ndc, orthoCamera) + const hits = raycaster.intersectObject(sphere) + return hits[0] ?? null +} const handleMouseDown = (event: MouseEvent): void => { - if (!orthoCamera || !sphere || !canvasElement) return - const rect = canvasElement.getBoundingClientRect() - const raycaster = new THREE.Raycaster() - raycaster.setFromCamera(getNdcFromEvent(event, rect), orthoCamera) - if (raycaster.intersectObject(sphere).length > 0) { - isDragging = true - hasDragged = false + if (!canvasElement) return + const hit = getIntersection(event) + + if (reactiveConfig.value.mode === 'paint' && hit?.uv) { + interactionMode = 'paint' + lastPaintUv = null + paintStroke(null, hit.uv) + lastPaintUv = hit.uv.clone() + canvasElement.style.cursor = 'crosshair' + return + } + + if (hit) { + interactionMode = 'rotate' dragLastX = event.clientX dragLastY = event.clientY canvasElement.style.cursor = 'grabbing' @@ -339,30 +316,36 @@ const handleMouseDown = (event: MouseEvent): void => { } const handleMouseUp = (): void => { - isDragging = false + interactionMode = 'none' as InteractionMode + lastPaintUv = null if (canvasElement) canvasElement.style.cursor = 'default' } const handleMouseMove = (event: MouseEvent): void => { - if (!orthoCamera || !sphere || !canvasElement) return - if (isDragging) { - sphere.rotation.y += (event.clientX - dragLastX) * PAINTER_DRAG_SENSITIVITY - sphere.rotation.x += (event.clientY - dragLastY) * PAINTER_DRAG_SENSITIVITY + if (!sphere || !canvasElement) return + + if (interactionMode === 'paint') { + const hit = getIntersection(event) + if (hit?.uv) { + paintStroke(lastPaintUv, hit.uv) + lastPaintUv = hit.uv.clone() + } + return + } + + if (interactionMode === 'rotate') { + const dx = event.clientX - dragLastX + const dy = event.clientY - dragLastY + sphere.rotation.y += dx * 0.008 + sphere.rotation.x += dy * 0.008 dragLastX = event.clientX dragLastY = event.clientY - hasDragged = true return } - const rect = canvasElement.getBoundingClientRect() - const raycaster = new THREE.Raycaster() - raycaster.setFromCamera(getNdcFromEvent(event, rect), orthoCamera) - canvasElement.style.cursor = raycaster.intersectObject(sphere).length > 0 ? 'grab' : 'default' -} -const handleClick = (): void => { - if (hasDragged) { - hasDragged = false - } + const hit = getIntersection(event) + const isPaint = reactiveConfig.value.mode === 'paint' + canvasElement.style.cursor = hit ? (isPaint ? 'crosshair' : 'grab') : 'default' } const createOrthoCamera = (): THREE.OrthographicCamera => { @@ -395,7 +378,6 @@ const handleResize = (): void => { const init = async (canvasReference: HTMLCanvasElement): Promise => { canvasElement = canvasReference - await initTextures() const { setup, renderer, scene } = await getTools({ canvas: canvasReference, resize: false }) @@ -411,7 +393,6 @@ const init = async (canvasReference: HTMLCanvasElement): Promise => { canvasReference.addEventListener('mousedown', handleMouseDown) canvasReference.addEventListener('mouseup', handleMouseUp) canvasReference.addEventListener('mousemove', handleMouseMove) - canvasReference.addEventListener('click', handleClick) await setup({ config: { @@ -500,7 +481,6 @@ onBeforeUnmount(() => { canvasElement.removeEventListener('mousedown', handleMouseDown) canvasElement.removeEventListener('mouseup', handleMouseUp) canvasElement.removeEventListener('mousemove', handleMouseMove) - canvasElement.removeEventListener('click', handleClick) } orbitControls?.dispose() Object.values(textures).forEach((t) => t.dispose()) @@ -512,47 +492,6 @@ onBeforeUnmount(() => { diff --git a/src/views/Experiments/TexturePainter/config.ts b/src/views/Experiments/TexturePainter/config.ts index 6b0c37b5..2dd44871 100644 --- a/src/views/Experiments/TexturePainter/config.ts +++ b/src/views/Experiments/TexturePainter/config.ts @@ -42,7 +42,6 @@ export const PAINTER_ORTHO_FRUSTUM = 5.0 export const PAINTER_ORTHO_NEAR = 0.1 export const PAINTER_ORTHO_FAR = 100 export const PAINTER_ORTHO_DISTANCE = 20 -export const PAINTER_DRAG_SENSITIVITY = 0.008 export const STORAGE_PREFIX = 'texture-painter' From fca4d32c0ca6683abfa240704f195f8c0b67195b Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 29 Apr 2026 10:10:34 +0200 Subject: [PATCH 04/10] feat: add reset texture buttons to TexturePainter config panel Co-Authored-By: Claude Sonnet 4.6 --- .../TexturePainter/TexturePainter.vue | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/views/Experiments/TexturePainter/TexturePainter.vue b/src/views/Experiments/TexturePainter/TexturePainter.vue index 022d5da7..3bdf9600 100644 --- a/src/views/Experiments/TexturePainter/TexturePainter.vue +++ b/src/views/Experiments/TexturePainter/TexturePainter.vue @@ -111,6 +111,8 @@ const configControls = { }, brushColor: { color: true, label: 'Color' }, brushSize: { min: 1, max: 80, step: 1, label: 'Brush Size' }, + resetTexture: { callback: 'resetTexture', label: 'Reset Texture' }, + resetAll: { callback: 'resetAll', label: 'Reset All Textures' }, properties: CONFIG_SCHEMA.properties } @@ -281,6 +283,20 @@ const paintStroke = (fromUv: THREE.Vector2 | null, toUv: THREE.Vector2): void => storageSaveLocal(storageKey(slot), offscreen.toDataURL()) } +const resetSlot = (slot: TextureSlotKey): void => { + const offscreen = offscreenCanvases[slot] + if (!offscreen) return + const ctx = offscreen.getContext('2d')! + ctx.clearRect(0, 0, offscreen.width, offscreen.height) + DEFAULT_DRAW_FUNCTIONS[slot](ctx, offscreen.width) + textures[slot].needsUpdate = true + storageSaveLocal(storageKey(slot), offscreen.toDataURL()) +} + +const resetTexture = (): void => resetSlot(reactiveConfig.value.activeSlot) + +const resetAll = (): void => TEXTURE_SLOTS.forEach(resetSlot) + const getIntersection = (event: MouseEvent): THREE.Intersection | null => { if (!orthoCamera || !sphere || !canvasElement) return null const rect = canvasElement.getBoundingClientRect() @@ -466,7 +482,16 @@ const init = async (canvasReference: HTMLCanvasElement): Promise => { onMounted(async () => { setViewPanels({ showConfig: true, showScene: true }) - registerViewConfig(route.name as string, reactiveConfig as never, configControls, rebuildMaterial) + registerViewConfig( + route.name as string, + reactiveConfig as never, + configControls, + rebuildMaterial, + { + resetTexture, + resetAll + } + ) registerSceneConfig(mapsSceneRegistration) if (canvas.value) await init(canvas.value) window.addEventListener('resize', handleResize) From 9c57c0f3a550413fb260cef7e713fa9d700a9ecb Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 29 Apr 2026 10:15:51 +0200 Subject: [PATCH 05/10] feat: abstract DrawingToolbar, use icon tools in TexturePainter - New src/components/DrawingToolbar: reusable icon toolbar with brush, eraser, fill, rotate, color picker, brush size - CanvasEditorTools refactored to use DrawingToolbar for core buttons - TexturePainter: replace Mode text toggle + config sliders with DrawingToolbar via Teleport; toolbar shows texture slot tabs, icon tool buttons, reset buttons - activeTool/brushColor/brushSize are local refs, not schema-driven Co-Authored-By: Claude Sonnet 4.6 --- .../CanvasEditor/CanvasEditorTools.vue | 58 ++---- .../DrawingToolbar/DrawingToolbar.vue | 100 ++++++++++ src/components/DrawingToolbar/index.ts | 2 + .../TexturePainter/TexturePainter.vue | 184 +++++++++++++----- 4 files changed, 248 insertions(+), 96 deletions(-) create mode 100644 src/components/DrawingToolbar/DrawingToolbar.vue create mode 100644 src/components/DrawingToolbar/index.ts diff --git a/src/components/CanvasEditor/CanvasEditorTools.vue b/src/components/CanvasEditor/CanvasEditorTools.vue index 8f39b75d..3f06dd72 100644 --- a/src/components/CanvasEditor/CanvasEditorTools.vue +++ b/src/components/CanvasEditor/CanvasEditorTools.vue @@ -1,9 +1,9 @@ + + + + diff --git a/src/components/DrawingToolbar/index.ts b/src/components/DrawingToolbar/index.ts new file mode 100644 index 00000000..027644d0 --- /dev/null +++ b/src/components/DrawingToolbar/index.ts @@ -0,0 +1,2 @@ +export { default as DrawingToolbar } from './DrawingToolbar.vue' +export type { DrawingTool, DrawingToolbarButton } from './DrawingToolbar.vue' diff --git a/src/views/Experiments/TexturePainter/TexturePainter.vue b/src/views/Experiments/TexturePainter/TexturePainter.vue index 3bdf9600..ac6c3d37 100644 --- a/src/views/Experiments/TexturePainter/TexturePainter.vue +++ b/src/views/Experiments/TexturePainter/TexturePainter.vue @@ -10,6 +10,8 @@ import { registerViewConfig, unregisterViewConfig, createReactiveConfig } from ' import { registerSceneConfig, unregisterSceneConfig } from '@/stores/sceneConfig' import { useViewPanelsStore } from '@/stores/viewPanels' import { buildMaterial } from '@/utils/materialBuilder' +import { DrawingToolbar } from '@/components/DrawingToolbar' +import type { DrawingTool } from '@/components/DrawingToolbar' import { MAIN_MATERIAL_TYPES, MATERIAL_LABELS, @@ -64,55 +66,34 @@ let orthoCamera: THREE.OrthographicCamera | null = null let rendererReference: THREE.WebGLRenderer | null = null let canvasElement: HTMLCanvasElement | null = null -type InteractionMode = 'paint' | 'rotate' -let interactionMode: InteractionMode = 'none' as InteractionMode +type ActiveMode = 'paint' | 'rotate' | 'none' +let activeMode: ActiveMode = 'none' let lastPaintUv: THREE.Vector2 | null = null let dragLastX = 0 let dragLastY = 0 +const activeTool = ref('brush') +const brushColor = ref(TEXTURE_SLOT_DEFAULT_COLOR.diffuse) +const brushSize = ref(20) + const textures: Record = {} const offscreenCanvases: Record = {} +const activeSlot = ref('diffuse') + const reactiveConfig = createReactiveConfig< - MaterialsListConfig & { - materialType: MaterialTypeName - activeSlot: TextureSlotKey - brushColor: string - brushSize: number - mode: InteractionMode - } + MaterialsListConfig & { materialType: MaterialTypeName } >({ materialType: 'MeshStandardMaterial', - activeSlot: 'diffuse', - brushColor: TEXTURE_SLOT_DEFAULT_COLOR.diffuse, - brushSize: 20, - mode: 'paint', ...DEFAULT_CONFIG }) const configControls = { - mode: { - label: 'Mode', - component: 'ButtonSelector' as const, - options: [ - { value: 'paint', label: 'Paint' }, - { value: 'rotate', label: 'Rotate' } - ] - }, materialType: { label: 'Material', component: 'ButtonSelector' as const, options: MAIN_MATERIAL_TYPES.map((t) => ({ value: t, label: MATERIAL_LABELS[t] })) }, - activeSlot: { - label: 'Paint on', - component: 'ButtonSelector' as const, - options: TEXTURE_SLOTS.map((s) => ({ value: s, label: TEXTURE_SLOT_LABELS[s] })) - }, - brushColor: { color: true, label: 'Color' }, - brushSize: { min: 1, max: 80, step: 1, label: 'Brush Size' }, - resetTexture: { callback: 'resetTexture', label: 'Reset Texture' }, - resetAll: { callback: 'resetAll', label: 'Reset All Textures' }, properties: CONFIG_SCHEMA.properties } @@ -254,18 +235,18 @@ const rebuildMaterial = (): void => { } const paintStroke = (fromUv: THREE.Vector2 | null, toUv: THREE.Vector2): void => { - const slot = reactiveConfig.value.activeSlot + const slot = activeSlot.value const offscreen = offscreenCanvases[slot] if (!offscreen) return const ctx = offscreen.getContext('2d')! const size = offscreen.width const toX = toUv.x * size const toY = (1 - toUv.y) * size - const radius = reactiveConfig.value.brushSize / 2 + const radius = brushSize.value / 2 - ctx.fillStyle = reactiveConfig.value.brushColor - ctx.strokeStyle = reactiveConfig.value.brushColor - ctx.lineWidth = reactiveConfig.value.brushSize + ctx.fillStyle = brushColor.value + ctx.strokeStyle = brushColor.value + ctx.lineWidth = brushSize.value ctx.lineCap = 'round' ctx.lineJoin = 'round' @@ -293,7 +274,7 @@ const resetSlot = (slot: TextureSlotKey): void => { storageSaveLocal(storageKey(slot), offscreen.toDataURL()) } -const resetTexture = (): void => resetSlot(reactiveConfig.value.activeSlot) +const resetTexture = (): void => resetSlot(activeSlot.value) const resetAll = (): void => TEXTURE_SLOTS.forEach(resetSlot) @@ -314,8 +295,9 @@ const handleMouseDown = (event: MouseEvent): void => { if (!canvasElement) return const hit = getIntersection(event) - if (reactiveConfig.value.mode === 'paint' && hit?.uv) { - interactionMode = 'paint' + const isPaintTool = activeTool.value !== 'rotate' + if (isPaintTool && hit?.uv) { + activeMode = 'paint' lastPaintUv = null paintStroke(null, hit.uv) lastPaintUv = hit.uv.clone() @@ -324,7 +306,7 @@ const handleMouseDown = (event: MouseEvent): void => { } if (hit) { - interactionMode = 'rotate' + activeMode = 'rotate' dragLastX = event.clientX dragLastY = event.clientY canvasElement.style.cursor = 'grabbing' @@ -332,7 +314,7 @@ const handleMouseDown = (event: MouseEvent): void => { } const handleMouseUp = (): void => { - interactionMode = 'none' as InteractionMode + activeMode = 'none' lastPaintUv = null if (canvasElement) canvasElement.style.cursor = 'default' } @@ -340,7 +322,7 @@ const handleMouseUp = (): void => { const handleMouseMove = (event: MouseEvent): void => { if (!sphere || !canvasElement) return - if (interactionMode === 'paint') { + if (activeMode === 'paint') { const hit = getIntersection(event) if (hit?.uv) { paintStroke(lastPaintUv, hit.uv) @@ -349,7 +331,7 @@ const handleMouseMove = (event: MouseEvent): void => { return } - if (interactionMode === 'rotate') { + if (activeMode === 'rotate') { const dx = event.clientX - dragLastX const dy = event.clientY - dragLastY sphere.rotation.y += dx * 0.008 @@ -360,7 +342,7 @@ const handleMouseMove = (event: MouseEvent): void => { } const hit = getIntersection(event) - const isPaint = reactiveConfig.value.mode === 'paint' + const isPaint = activeTool.value !== 'rotate' canvasElement.style.cursor = hit ? (isPaint ? 'crosshair' : 'grab') : 'default' } @@ -482,16 +464,7 @@ const init = async (canvasReference: HTMLCanvasElement): Promise => { onMounted(async () => { setViewPanels({ showConfig: true, showScene: true }) - registerViewConfig( - route.name as string, - reactiveConfig as never, - configControls, - rebuildMaterial, - { - resetTexture, - resetAll - } - ) + registerViewConfig(route.name as string, reactiveConfig as never, configControls, rebuildMaterial) registerSceneConfig(mapsSceneRegistration) if (canvas.value) await init(canvas.value) window.addEventListener('resize', handleResize) @@ -517,8 +490,113 @@ onBeforeUnmount(() => { + + - - diff --git a/src/views/Experiments/TexturePainter/config.ts b/src/views/Experiments/TexturePainter/config.ts index e4e751f8..b39b6443 100644 --- a/src/views/Experiments/TexturePainter/config.ts +++ b/src/views/Experiments/TexturePainter/config.ts @@ -34,202 +34,7 @@ export { export type { MaterialTypeName, MaterialsListConfig } from '@/views/Experiments/MaterialsList/types' -export type PresetKey = 'glass' | 'metal' | 'rock' | 'magic' | 'cutout' - -export const PRESET_LABELS: Record = { - glass: 'Glass', - metal: 'Metal', - rock: 'Rock', - magic: 'Magic', - cutout: 'Cutout' -} - -export interface PresetValues { - materialType: string - properties: { - wireframe: boolean - roughness: number - metalness: number - shininess: number - clearcoat: number - transmission: number - flatShading: boolean - } - strengths: { - normalScale: number - aoIntensity: number - displacementScale: number - emissiveIntensity: number - envMapIntensity: number - } - maps: { - diffuse: boolean - normal: boolean - roughnessMap: boolean - metalnessMap: boolean - ao: boolean - displacement: boolean - emissive: boolean - envMapEnabled: boolean - gradientMap: boolean - } -} - -export const PRESETS: Record = { - glass: { - materialType: 'MeshPhysicalMaterial', - properties: { - wireframe: false, - roughness: 0.0, - metalness: 0, - shininess: 300, - clearcoat: 1, - transmission: 0.95, - flatShading: false - }, - strengths: { - normalScale: 0.3, - aoIntensity: 0, - displacementScale: 0, - emissiveIntensity: 0, - envMapIntensity: 2.5 - }, - maps: { - diffuse: false, - normal: true, - roughnessMap: false, - metalnessMap: false, - ao: false, - displacement: false, - emissive: false, - envMapEnabled: true, - gradientMap: false - } - }, - metal: { - materialType: 'MeshStandardMaterial', - properties: { - wireframe: false, - roughness: 0.25, - metalness: 1, - shininess: 100, - clearcoat: 0, - transmission: 0, - flatShading: false - }, - strengths: { - normalScale: 3, - aoIntensity: 1, - displacementScale: 0, - emissiveIntensity: 0, - envMapIntensity: 2 - }, - maps: { - diffuse: true, - normal: true, - roughnessMap: true, - metalnessMap: false, - ao: true, - displacement: false, - emissive: false, - envMapEnabled: true, - gradientMap: false - } - }, - rock: { - materialType: 'MeshStandardMaterial', - properties: { - wireframe: false, - roughness: 0.9, - metalness: 0, - shininess: 10, - clearcoat: 0, - transmission: 0, - flatShading: false - }, - strengths: { - normalScale: 5, - aoIntensity: 1.5, - displacementScale: 0.4, - emissiveIntensity: 0, - envMapIntensity: 0.2 - }, - maps: { - diffuse: true, - normal: true, - roughnessMap: true, - metalnessMap: false, - ao: true, - displacement: true, - emissive: false, - envMapEnabled: false, - gradientMap: false - } - }, - magic: { - materialType: 'MeshStandardMaterial', - properties: { - wireframe: false, - roughness: 0.1, - metalness: 0.2, - shininess: 100, - clearcoat: 0.8, - transmission: 0, - flatShading: false - }, - strengths: { - normalScale: 1, - aoIntensity: 0, - displacementScale: 0, - emissiveIntensity: 4, - envMapIntensity: 1 - }, - maps: { - diffuse: true, - normal: false, - roughnessMap: false, - metalnessMap: false, - ao: false, - displacement: false, - emissive: true, - envMapEnabled: true, - gradientMap: false - } - }, - cutout: { - materialType: 'MeshStandardMaterial', - properties: { - wireframe: false, - roughness: 0.7, - metalness: 0, - shininess: 30, - clearcoat: 0, - transmission: 0, - flatShading: true - }, - strengths: { - normalScale: 2, - aoIntensity: 2, - displacementScale: 0.8, - emissiveIntensity: 0, - envMapIntensity: 0.5 - }, - maps: { - diffuse: true, - normal: false, - roughnessMap: false, - metalnessMap: false, - ao: true, - displacement: true, - emissive: false, - envMapEnabled: true, - gradientMap: false - } - } -} - export const PAINTER_SPHERE_RADIUS = 2.5 -export const PAINTER_SPHERE_SEGMENTS = 64 export const PAINTER_CANVAS_SIZE = 512 export const PAINTER_LIGHT_ORBIT_SPEED = 0.3 export const PAINTER_TARGET_FPS = 60