From 12b62926234772bf486a73e977757778f5c956fc Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 30 Mar 2026 12:15:16 +0800 Subject: [PATCH] feat: add material system for all node types - Add MaterialSchema with 10 presets (white, brick, concrete, wood, glass, metal, plaster, tile, marble, custom) - Add material field to Wall, Slab, Door, Window, Ceiling, Roof, RoofSegment nodes - Create MaterialPicker UI component with preset selection and custom properties - Update all renderers to support material rendering with caching - Add Material section to all node panels (WallPanel, SlabPanel, DoorPanel, WindowPanel, CeilingPanel, RoofPanel, RoofSegmentPanel) - Update AGENTS.md with Material System documentation --- AGENTS.md | 96 ++++++++++ bun.lock | 19 +- package.json | 3 - packages/core/package.json | 2 +- packages/core/src/schema/index.ts | 10 +- packages/core/src/schema/material.ts | 70 +++++++ packages/core/src/schema/nodes/ceiling.ts | 4 +- packages/core/src/schema/nodes/door.ts | 2 + .../core/src/schema/nodes/roof-segment.ts | 3 +- packages/core/src/schema/nodes/roof.ts | 3 +- packages/core/src/schema/nodes/slab.ts | 4 +- packages/core/src/schema/nodes/wall.ts | 3 +- packages/core/src/schema/nodes/window.ts | 3 +- .../ui/controls/material-picker.tsx | 178 ++++++++++++++++++ .../components/ui/panels/ceiling-panel.tsx | 14 +- .../src/components/ui/panels/door-panel.tsx | 10 +- .../src/components/ui/panels/roof-panel.tsx | 13 ++ .../ui/panels/roof-segment-panel.tsx | 13 ++ .../src/components/ui/panels/slab-panel.tsx | 14 +- .../src/components/ui/panels/wall-panel.tsx | 18 +- .../src/components/ui/panels/window-panel.tsx | 14 +- .../renderers/ceiling/ceiling-renderer.tsx | 68 ++++--- .../renderers/door/door-renderer.tsx | 10 +- .../renderers/roof/roof-renderer.tsx | 11 +- .../renderers/slab/slab-renderer.tsx | 11 +- .../renderers/wall/wall-renderer.tsx | 12 +- .../renderers/window/window-renderer.tsx | 10 +- packages/viewer/src/index.ts | 12 ++ packages/viewer/src/lib/materials.ts | 69 +++++++ 29 files changed, 613 insertions(+), 86 deletions(-) create mode 100644 packages/core/src/schema/material.ts create mode 100644 packages/editor/src/components/ui/controls/material-picker.tsx create mode 100644 packages/viewer/src/lib/materials.ts diff --git a/AGENTS.md b/AGENTS.md index 3d1132a4..c290b244 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,3 +89,99 @@ User input (pointer/keyboard) | State | Zustand + Zundo | | UI | Radix UI, Tailwind CSS 4 | | Tooling | Biome, TypeScript 5.9, Turborepo | + +--- + +## Material System + +The editor supports a flexible material system for all node types. + +### Schema Definition + +Materials are defined in `packages/core/src/schema/material.ts`: + +```typescript +// Preset materials +type MaterialPreset = 'white' | 'brick' | 'concrete' | 'wood' | 'glass' | 'metal' | 'plaster' | 'tile' | 'marble' | 'custom' + +// Material properties +type MaterialProperties = { + color: string // Hex color + roughness: number // 0-1 + metalness: number // 0-1 + opacity: number // 0-1 + transparent: boolean + side: 'front' | 'back' | 'double' +} + +// Full material schema +type MaterialSchema = { + preset?: MaterialPreset + properties?: MaterialProperties + texture?: { url: string, repeat?: [number, number] } +} +``` + +### Supported Nodes + +All major node types support the `material` field: + +| Node Type | Schema File | Panel Component | +|-----------|-------------|-----------------| +| Wall | `nodes/wall.ts` | `WallPanel` | +| Slab | `nodes/slab.ts` | `SlabPanel` | +| Door | `nodes/door.ts` | `DoorPanel` | +| Window | `nodes/window.ts` | `WindowPanel` | +| Ceiling | `nodes/ceiling.ts` | `CeilingPanel` | +| Roof | `nodes/roof.ts` | `RoofPanel` | +| RoofSegment | `nodes/roof-segment.ts` | `RoofSegmentPanel` | + +### Usage + +**In Editor UI:** +1. Select a node (wall, slab, door, etc.) +2. Find the "Material" section in the right panel +3. Click a preset color or select "Custom" to adjust properties + +**Programmatically:** + +```typescript +import { WallNode } from '@pascal-app/core' + +// Using preset +const wall = WallNode.parse({ + start: [0, 0], + end: [5, 0], + material: { preset: 'brick' } +}) + +// Custom material +const slab = SlabNode.parse({ + polygon: [[0,0], [5,0], [5,5], [0,5]], + material: { + preset: 'custom', + properties: { + color: '#8b4513', + roughness: 0.7, + metalness: 0.1, + opacity: 1, + transparent: false, + side: 'front' + } + } +}) +``` + +### Renderer Integration + +Renderers use `createMaterial()` from `packages/viewer/src/lib/materials.ts`: + +```typescript +import { createMaterial, DEFAULT_WALL_MATERIAL } from '@pascal-app/viewer' + +const material = useMemo(() => { + return node.material ? createMaterial(node.material) : DEFAULT_WALL_MATERIAL +}, [node.material]) + +return +``` diff --git a/bun.lock b/bun.lock index 83ad906f..91bc61f7 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "editor", - "dependencies": { - "portless": "^0.4.2", - }, "devDependencies": { "@biomejs/biome": "^2.4.6", "dotenv-cli": "^11.0.0", @@ -51,7 +48,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -73,7 +70,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.183", + "three": "^0.182", }, }, "packages/editor": { @@ -169,7 +166,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", @@ -727,7 +724,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], @@ -1173,8 +1170,6 @@ "polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="], - "portless": ["portless@0.4.2", "", { "dependencies": { "chalk": "^5.3.0" }, "os": [ "linux", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-/G3jIeD1XokoO9KY/lUGTV9irKz3tgx8yqHkz+hvj/86QeR219GvYFB+QEfpvjRlvvLVJa5vE7BkRfBZBe/lQg=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], @@ -1473,20 +1468,22 @@ "editor/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], - "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "eslint-plugin-turbo/dotenv": ["dotenv@16.0.3", "", {}, "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "react-scan/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], diff --git a/package.json b/package.json index 8b094d0e..10b3d7f5 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,6 @@ "release:minor": "gh workflow run release.yml -f package=both -f bump=minor", "release:major": "gh workflow run release.yml -f package=both -f bump=major" }, - "dependencies": { - "portless": "^0.4.2" - }, "devDependencies": { "@biomejs/biome": "^2.4.6", "dotenv-cli": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index f92652c8..804f3f19 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.183" + "three": "^0.182" }, "dependencies": { "dedent": "^1.7.1", diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e8f90cca..9cf6b33b 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,9 +1,17 @@ // Base -export { BaseNode, generateId, Material, nodeType, objectId } from './base' +export { BaseNode, generateId, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' // Collections export { type Collection, type CollectionId, generateCollectionId } from './collections' +// Material +export { + DEFAULT_MATERIALS, + MaterialPreset, + MaterialProperties, + MaterialSchema, + resolveMaterial, +} from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts new file mode 100644 index 00000000..a43d5edc --- /dev/null +++ b/packages/core/src/schema/material.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' + +export const MaterialPreset = z.enum([ + 'white', + 'brick', + 'concrete', + 'wood', + 'glass', + 'metal', + 'plaster', + 'tile', + 'marble', + 'custom', +]) +export type MaterialPreset = z.infer + +export const MaterialProperties = z.object({ + color: z.string().default('#ffffff'), + roughness: z.number().min(0).max(1).default(0.5), + metalness: z.number().min(0).max(1).default(0), + opacity: z.number().min(0).max(1).default(1), + transparent: z.boolean().default(false), + side: z.enum(['front', 'back', 'double']).default('front'), +}) +export type MaterialProperties = z.infer + +export const MaterialSchema = z.object({ + preset: MaterialPreset.optional(), + properties: MaterialProperties.optional(), + texture: z + .object({ + url: z.string(), + repeat: z.tuple([z.number(), z.number()]).optional(), + scale: z.number().optional(), + }) + .optional(), +}) +export type MaterialSchema = z.infer + +export const DEFAULT_MATERIALS: Record = { + white: { color: '#ffffff', roughness: 0.9, metalness: 0, opacity: 1, transparent: false, side: 'front' }, + brick: { color: '#8b4513', roughness: 0.85, metalness: 0, opacity: 1, transparent: false, side: 'front' }, + concrete: { color: '#808080', roughness: 0.8, metalness: 0, opacity: 1, transparent: false, side: 'front' }, + wood: { color: '#deb887', roughness: 0.7, metalness: 0, opacity: 1, transparent: false, side: 'front' }, + glass: { color: '#87ceeb', roughness: 0.1, metalness: 0.1, opacity: 0.3, transparent: true, side: 'double' }, + metal: { color: '#c0c0c0', roughness: 0.3, metalness: 0.9, opacity: 1, transparent: false, side: 'front' }, + plaster: { color: '#f5f5dc', roughness: 0.95, metalness: 0, opacity: 1, transparent: false, side: 'front' }, + tile: { color: '#d3d3d3', roughness: 0.4, metalness: 0.1, opacity: 1, transparent: false, side: 'front' }, + marble: { color: '#fafafa', roughness: 0.2, metalness: 0.1, opacity: 1, transparent: false, side: 'front' }, + custom: { color: '#ffffff', roughness: 0.5, metalness: 0, opacity: 1, transparent: false, side: 'front' }, +} + +export function resolveMaterial(material?: MaterialSchema): MaterialProperties { + if (!material) { + return DEFAULT_MATERIALS.white + } + + if (material.preset && material.preset !== 'custom') { + const presetProps = DEFAULT_MATERIALS[material.preset] + return { + ...presetProps, + ...material.properties, + } + } + + return { + ...DEFAULT_MATERIALS.custom, + ...material.properties, + } +} diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index b6e76787..21d686a6 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -1,14 +1,14 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' import { ItemNode } from './item' export const CeilingNode = BaseNode.extend({ id: objectId('ceiling'), type: nodeType('ceiling'), children: z.array(ItemNode.shape.id).default([]), - // Specific props - // Polygon boundary - array of [x, z] coordinates defining the ceiling + material: MaterialSchema.optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), height: z.number().default(2.5), // Height in meters diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index e8a8fea3..9d8ad95d 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' export const DoorSegment = z.object({ type: z.enum(['panel', 'glass', 'empty']), @@ -20,6 +21,7 @@ export type DoorSegment = z.infer export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), + material: MaterialSchema.optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 08852775..9b43858c 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat']) @@ -9,7 +10,7 @@ export type RoofType = z.infer export const RoofSegmentNode = BaseNode.extend({ id: objectId('rseg'), type: nodeType('roof-segment'), - // Position relative to parent roof group + material: MaterialSchema.optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 488d34a9..da594da5 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -1,12 +1,13 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' import { RoofSegmentNode } from './roof-segment' export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), - // Position of the roof group center + material: MaterialSchema.optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 1edb28a8..765921ce 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -1,12 +1,12 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), - // Specific props - // Polygon boundary - array of [x, z] coordinates defining the slab + material: MaterialSchema.optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), elevation: z.number().default(0.05), // Elevation in meters diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 143419a5..8c25988f 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' import { ItemNode } from './item' // import { DoorNode } from "./door"; // import { ItemNode } from "./item"; @@ -10,7 +11,7 @@ export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), children: z.array(ItemNode.shape.id).default([]), - // Specific props + material: MaterialSchema.optional(), thickness: z.number().optional(), height: z.number().optional(), // e.g., start/end points for path diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index a470cd16..7e28b71f 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -1,12 +1,13 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), + material: MaterialSchema.optional(), - // Position in wall-local coordinate system (center of window) position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), side: z.enum(['front', 'back']).optional(), diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx new file mode 100644 index 00000000..d74ef247 --- /dev/null +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -0,0 +1,178 @@ +'use client' + +import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core' +import { useState } from 'react' + +const PRESET_COLORS: Record = { + white: '#ffffff', + brick: '#8b4513', + concrete: '#808080', + wood: '#deb887', + glass: '#87ceeb', + metal: '#c0c0c0', + plaster: '#f5f5dc', + tile: '#d3d3d3', + marble: '#fafafa', + custom: '#ffffff', +} + +const PRESET_LABELS: Record = { + white: 'White', + brick: 'Brick', + concrete: 'Concrete', + wood: 'Wood', + glass: 'Glass', + metal: 'Metal', + plaster: 'Plaster', + tile: 'Tile', + marble: 'Marble', + custom: 'Custom', +} + +type MaterialPickerProps = { + value?: MaterialSchema + onChange: (material: MaterialSchema) => void +} + +export function MaterialPicker({ value, onChange }: MaterialPickerProps) { + const [showCustom, setShowCustom] = useState(value?.preset === 'custom' || !!value?.properties) + + const currentPreset = value?.preset || 'white' + const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] + + const handlePresetChange = (preset: MaterialPreset) => { + if (preset === 'custom') { + setShowCustom(true) + onChange({ + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', + }, + }) + } else { + setShowCustom(false) + onChange({ preset }) + } + } + + const handlePropertyChange = (prop: keyof typeof currentProps, val: typeof currentProps[keyof typeof currentProps]) => { + onChange({ + preset: showCustom ? 'custom' : currentPreset, + properties: { + ...currentProps, + [prop]: val, + }, + }) + } + + return ( +
+
+ {(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => ( +
+ + {showCustom && ( +
+
+ + handlePropertyChange('color', e.target.value)} + type="color" + value={currentProps.color} + /> + handlePropertyChange('color', e.target.value)} + type="text" + value={currentProps.color} + /> +
+ +
+ + handlePropertyChange('roughness', parseFloat(e.target.value))} + step={0.01} + type="range" + value={currentProps.roughness} + /> + {currentProps.roughness.toFixed(2)} +
+ +
+ + handlePropertyChange('metalness', parseFloat(e.target.value))} + step={0.01} + type="range" + value={currentProps.metalness} + /> + {currentProps.metalness.toFixed(2)} +
+ +
+ + { + const opacity = parseFloat(e.target.value) + handlePropertyChange('opacity', opacity) + if (opacity < 1 && !currentProps.transparent) { + handlePropertyChange('transparent', true) + } + }} + step={0.01} + type="range" + value={currentProps.opacity} + /> + {currentProps.opacity.toFixed(2)} +
+ +
+ + +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 71ec87c1..ee85f956 100644 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -1,11 +1,12 @@ 'use client' -import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import useEditor from '../../../store/use-editor' import { ActionButton } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -94,6 +95,10 @@ export function CeilingPanel() { [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], ) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null const calculateArea = (polygon: Array<[number, number]>): number => { @@ -217,6 +222,13 @@ export function CeilingPanel() { /> + + + + ) } diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index a2571aa1..71df522a 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type MaterialSchema, DoorNode, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' @@ -8,6 +8,7 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -562,6 +563,13 @@ export function DoorPanel() { + + handleUpdate({ material })} + value={node.material} + /> + + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 59058d39..175a822b 100644 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,6 +3,7 @@ import { type AnyNode, type AnyNodeId, + type MaterialSchema, type RoofNode, RoofNode as RoofNodeSchema, type RoofSegmentNode, @@ -15,6 +16,7 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -122,6 +124,10 @@ export function RoofPanel() { setSelection({ selectedIds: [] }) }, [selectedId, node, setSelection]) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null const segments = (node.children ?? []) @@ -229,6 +235,13 @@ export function RoofPanel() { + + + + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx index 086464ea..b509a281 100644 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -3,6 +3,7 @@ import { type AnyNode, type AnyNodeId, + type MaterialSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -14,6 +15,7 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -108,6 +110,10 @@ export function RoofSegmentPanel() { } }, [selectedId, node, setSelection]) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null return ( @@ -293,6 +299,13 @@ export function RoofSegmentPanel() { + + + + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index c0f83c93..094c0227 100644 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -1,11 +1,12 @@ 'use client' -import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -29,6 +30,10 @@ export function SlabPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -216,6 +221,13 @@ export function SlabPanel() { /> + + + + ) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 26c121e2..33fa1d48 100644 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -1,8 +1,9 @@ 'use client' -import { type AnyNode, type AnyNodeId, useScene, type WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type MaterialSchema, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' +import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -25,7 +26,6 @@ export function WallPanel() { [selectedId, updateNode], ) - // Função mágica para a Issue #191: Atualiza o comprimento via cálculo vetorial const handleUpdateLength = useCallback((newLength: number) => { if (!node || newLength <= 0) return @@ -35,11 +35,9 @@ export function WallPanel() { if (currentLength === 0) return - // Calcula a direção (vetor unitário) const dirX = dx / currentLength const dirZ = dz / currentLength - // Define o novo ponto final baseado no novo comprimento const newEnd: [number, number] = [ node.start[0] + dirX * newLength, node.start[1] + dirZ * newLength @@ -48,6 +46,10 @@ export function WallPanel() { handleUpdate({ end: newEnd }) }, [node, handleUpdate]) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -69,7 +71,6 @@ export function WallPanel() { width={280} > - {/* Adicionando o controle de Length solicitado na Issue #191 */} + + + + ) } diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index ddce225d..2fcb7278 100644 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type AnyNodeId, emitter, useScene, WindowNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, emitter, type MaterialSchema, useScene, WindowNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' @@ -8,6 +8,7 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' +import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -138,6 +139,10 @@ export function WindowPanel() { [handleUpdate], ) + const handleMaterialChange = useCallback((material: MaterialSchema) => { + handleUpdate({ material }) + }, [handleUpdate]) + if (!node || node.type !== 'window' || selectedIds.length !== 1) return null const numCols = node.columnRatios.length @@ -402,6 +407,13 @@ export function WindowPanel() { )} + + + + } label="Move" onClick={handleMove} /> diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 80042c38..1c1d05ff 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,49 +1,37 @@ import { type CeilingNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, DEFAULT_CEILING_MATERIAL } from '../../../lib/materials' import { NodeRenderer } from '../node-renderer' -// TSL material that renders differently based on face direction: -// - Back face (looking up at ceiling from below): solid -// - Front face (looking down at ceiling from above): 30% opacity -const ceilingTopMaterial = new MeshBasicNodeMaterial({ - color: 0xb5_a7_8d, - transparent: true, - depthWrite: false, - side: FrontSide, - // Disabled as we only show ceiling grid when needed - // alphaTestNode: float(0.4), // Discard pixels with alpha below 0.4 to create grid lines and not affect depth buffer -}) - -const ceilingBottomMaterial = new MeshBasicNodeMaterial({ - color: 0x99_99_99, - transparent: true, - side: BackSide, -}) - -// Create grid pattern based on local position -const gridScale = 5 // Grid cells per meter (1 = 1m grid) +const gridScale = 5 const gridX = positionWorld.x.mul(gridScale).fract() const gridY = positionWorld.z.mul(gridScale).fract() - -// Create grid lines - they are at 0 and 1 -const lineWidth = 0.05 // Width of grid lines (0-1 range within cell) - -// Create visible lines at edges (near 0 and near 1) +const lineWidth = 0.05 const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) - -// Combine: if either X or Y is a line, show the line const gridPattern = lineX.max(lineY) - -// Grid lines at 0.6 opacity, spaces at 0.2 opacity const gridOpacity = mix(float(0.2), float(0.6), gridPattern) -// faceDirection is 1.0 for front face, -1.0 for back face -// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid -ceilingTopMaterial.opacityNode = gridOpacity +function createCeilingMaterials(color: string = '#999999') { + const topMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthWrite: false, + side: FrontSide, + }) + topMaterial.opacityNode = gridOpacity + + const bottomMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + side: BackSide, + }) + + return { topMaterial, bottomMaterial } +} export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) @@ -51,12 +39,20 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') + const materials = useMemo(() => { + if (node.material) { + const props = node.material.properties + const color = props?.color || '#999999' + return createCeilingMaterials(color) + } + return { topMaterial: createCeilingMaterials().topMaterial, bottomMaterial: DEFAULT_CEILING_MATERIAL } + }, [node.material]) + return ( - - {/* CeilingSystem will replace this geometry in the next frame */} + { const ref = useRef(null!) @@ -10,9 +11,14 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient + const material = useMemo(() => { + return node.material ? createMaterial(node.material) : DEFAULT_DOOR_MATERIAL + }, [node.material]) + return ( { visible={node.visible} {...(isTransient ? {} : handlers)} > - {/* DoorSystem replaces this geometry each time the node is dirty */} - ) } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 01cff317..d6694fe7 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,7 +1,8 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import type * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, DEFAULT_ROOF_MATERIAL } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -14,6 +15,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const customMaterial = useMemo(() => { + return node.material ? createMaterial(node.material) : null + }, [node.material]) + + const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + return ( { > diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index b1d11b9c..37f267d2 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,7 +1,8 @@ import { type SlabNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials' export const SlabRenderer = ({ node }: { node: SlabNode }) => { const ref = useRef(null!) @@ -10,11 +11,13 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const handlers = useNodeEvents(node, 'slab') + const material = useMemo(() => { + return node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL + }, [node.material]) + return ( - - {/* SlabSystem will replace this geometry in the next frame */} + - ) } diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index e259094f..a01ca1e0 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,7 +1,8 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' -import { useLayoutEffect, useRef } from 'react' +import { useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, DEFAULT_WALL_MATERIAL } from '../../../lib/materials' import { NodeRenderer } from '../node-renderer' export const WallRenderer = ({ node }: { node: WallNode }) => { @@ -9,18 +10,19 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { useRegistry(node.id, 'wall', ref) - // Mark dirty on mount so WallSystem rebuilds geometry when wall (re)appears useLayoutEffect(() => { useScene.getState().markDirty(node.id) }, [node.id]) const handlers = useNodeEvents(node, 'wall') + const material = useMemo(() => { + return node.material ? createMaterial(node.material) : DEFAULT_WALL_MATERIAL + }, [node.material]) + return ( - - {/* WallSystem will replace this geometry in the next frame */} + - {/* Collision mesh: full-wall geometry (no cutouts) for pointer events */} diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx index b093d9e1..c6c61d24 100644 --- a/packages/viewer/src/components/renderers/window/window-renderer.tsx +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -1,7 +1,8 @@ import { useRegistry, type WindowNode } from '@pascal-app/core' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials' export const WindowRenderer = ({ node }: { node: WindowNode }) => { const ref = useRef(null!) @@ -10,9 +11,14 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { const handlers = useNodeEvents(node, 'window') const isTransient = !!(node.metadata as Record | null)?.isTransient + const material = useMemo(() => { + return node.material ? createMaterial(node.material) : DEFAULT_WINDOW_MATERIAL + }, [node.material]) + return ( { visible={node.visible} {...(isTransient ? {} : handlers)} > - {/* WindowSystem replaces this geometry each time the node is dirty */} - ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index a14b35c3..52c94344 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,6 +1,18 @@ export { default as Viewer } from './components/viewer' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' +export { + clearMaterialCache, + createDefaultMaterial, + createMaterial, + DEFAULT_CEILING_MATERIAL, + DEFAULT_DOOR_MATERIAL, + DEFAULT_ROOF_MATERIAL, + DEFAULT_SLAB_MATERIAL, + DEFAULT_WALL_MATERIAL, + DEFAULT_WINDOW_MATERIAL, + disposeMaterial, +} from './lib/materials' export { default as useViewer } from './store/use-viewer' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts new file mode 100644 index 00000000..97e5b46c --- /dev/null +++ b/packages/viewer/src/lib/materials.ts @@ -0,0 +1,69 @@ +import { type MaterialProperties, type MaterialSchema, resolveMaterial } from '@pascal-app/core' +import * as THREE from 'three' + +const sideMap: Record = { + front: THREE.FrontSide, + back: THREE.BackSide, + double: THREE.DoubleSide, +} + +const materialCache = new Map() + +function getCacheKey(props: MaterialProperties): string { + return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` +} + +export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial { + const props = resolveMaterial(material) + const cacheKey = getCacheKey(props) + + if (materialCache.has(cacheKey)) { + return materialCache.get(cacheKey)! + } + + const threeMaterial = new THREE.MeshStandardMaterial({ + color: props.color, + roughness: props.roughness, + metalness: props.metalness, + opacity: props.opacity, + transparent: props.transparent, + side: sideMap[props.side], + }) + + materialCache.set(cacheKey, threeMaterial) + return threeMaterial +} + +export function createDefaultMaterial(color: string = '#ffffff', roughness: number = 0.9): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color, + roughness, + metalness: 0, + side: THREE.FrontSide, + }) +} + +export const DEFAULT_WALL_MATERIAL = createDefaultMaterial('#ffffff', 0.9) +export const DEFAULT_SLAB_MATERIAL = createDefaultMaterial('#e5e5e5', 0.8) +export const DEFAULT_DOOR_MATERIAL = createDefaultMaterial('#8b4513', 0.7) +export const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({ + color: '#87ceeb', + roughness: 0.1, + metalness: 0.1, + opacity: 0.3, + transparent: true, + side: THREE.DoubleSide, +}) +export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95) +export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85) + +export function disposeMaterial(material: THREE.Material): void { + material.dispose() +} + +export function clearMaterialCache(): void { + for (const material of materialCache.values()) { + material.dispose() + } + materialCache.clear() +}