From 12b62926234772bf486a73e977757778f5c956fc Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 30 Mar 2026 12:15:16 +0800 Subject: [PATCH 1/4] 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() +} From d0cdeb2ac403de3f161274e7442884500389f65d Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 30 Mar 2026 20:21:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(viewer):=20=E4=BF=AE=E5=A4=8D=E5=A2=99?= =?UTF-8?q?=E4=BD=93=E6=9D=90=E8=B4=A8=E5=88=87=E6=8D=A2=E5=90=8E=E7=BC=A9?= =?UTF-8?q?=E6=94=BE=E6=97=8B=E8=BD=AC=E6=97=B6=E9=A2=9C=E8=89=B2=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题描述: 选中墙体并切换材质颜色后,在界面上进行缩放、旋转或移动操作时, 墙体实际渲染的颜色会恢复为原来的白色,但颜色选项卡显示仍为选中的颜色。 根本原因: WallCutout 系统在每次相机移动时,直接使用固定的白色材质覆盖墙体材质, 完全忽略了用户在节点上设置的自定义材质。 修复内容: - 为每个墙体创建独立的材质对象,支持用户自定义颜色 - 支持预设颜色(brick, concrete, wood 等)和自定义颜色属性 - 添加材质哈希值跟踪,检测材质变化并重新创建材质 - 提取 getWallHideState() 辅助函数,优化代码结构 - 添加预设颜色映射的类型安全改进,使用 as const 和 keyof 类型操作 --- .../renderers/ceiling/ceiling-renderer.tsx | 7 +- .../renderers/door/door-renderer.tsx | 6 +- .../roof-segment/roof-segment-renderer.tsx | 15 +- .../renderers/roof/roof-renderer.tsx | 6 +- .../renderers/slab/slab-renderer.tsx | 6 +- .../renderers/wall/wall-renderer.tsx | 6 +- .../renderers/window/window-renderer.tsx | 6 +- .../viewer/src/systems/wall/wall-cutout.tsx | 196 +++++++++++++----- 8 files changed, 180 insertions(+), 68 deletions(-) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 1c1d05ff..e1d2717c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -40,13 +40,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const handlers = useNodeEvents(node, 'ceiling') const materials = useMemo(() => { - if (node.material) { - const props = node.material.properties + const mat = node.material + if (mat) { + const props = mat.properties const color = props?.color || '#999999' return createCeilingMaterials(color) } return { topMaterial: createCeilingMaterials().topMaterial, bottomMaterial: DEFAULT_CEILING_MATERIAL } - }, [node.material]) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx index d268903c..9d87eba1 100644 --- a/packages/viewer/src/components/renderers/door/door-renderer.tsx +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -12,8 +12,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const isTransient = !!(node.metadata as Record | null)?.isTransient const material = useMemo(() => { - return node.material ? createMaterial(node.material) : DEFAULT_DOOR_MATERIAL - }, [node.material]) + const mat = node.material + if (!mat) return DEFAULT_DOOR_MATERIAL + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( { const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) + const customMaterial = useMemo(() => { + const mat = node.material + if (!mat) return null + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + + const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + return ( { ) -} +} \ No newline at end of file diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index d6694fe7..e5c88b51 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -16,8 +16,10 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const debugColors = useViewer((s) => s.debugColors) const customMaterial = useMemo(() => { - return node.material ? createMaterial(node.material) : null - }, [node.material]) + const mat = node.material + if (!mat) return null + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 37f267d2..f33b6ec0 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -12,8 +12,10 @@ 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]) + const mat = node.material + if (!mat) return DEFAULT_SLAB_MATERIAL + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index a01ca1e0..1f54e9ec 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -17,8 +17,10 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { const handlers = useNodeEvents(node, 'wall') const material = useMemo(() => { - return node.material ? createMaterial(node.material) : DEFAULT_WALL_MATERIAL - }, [node.material]) + const mat = node.material + if (!mat) return DEFAULT_WALL_MATERIAL + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx index c6c61d24..1f5c2c8e 100644 --- a/packages/viewer/src/components/renderers/window/window-renderer.tsx +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -12,8 +12,10 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { const isTransient = !!(node.metadata as Record | null)?.isTransient const material = useMemo(() => { - return node.material ? createMaterial(node.material) : DEFAULT_WINDOW_MATERIAL - }, [node.material]) + const mat = node.material + if (!mat) return DEFAULT_WINDOW_MATERIAL + return createMaterial(mat) + }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( { - // Create a repeating grid pattern based on world position - const scale = float(0.1) // Dot grid spacing (10cm) - const dotSize = float(0.3) // Size of dots relative to grid + const scale = float(0.1) + const dotSize = float(0.3) - // Use XY coordinates for pattern on wall face const uv = vec2(positionLocal.x, positionLocal.y).div(scale) const gridUV = fract(uv) - // Distance from center of grid cell (creates circular dots) const dist = length(gridUV.sub(0.5)) - // Create dots: 1 where we want dots, 0 elsewhere const dots = step(dist, dotSize.mul(0.5)) - // Vertical fade: fade out as Y increases (from bottom to top) - const fadeHeight = float(2.5) // Fade over 2.5 meters + const fadeHeight = float(2.5) const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y)) return dots.mul(yFade) }) -const invsibleWallMaterial = new MeshStandardNodeMaterial({ - transparent: true, - opacityNode: mix(float(0.0), float(0.24), dotPattern()), - color: 'white', - depthWrite: false, - emissive: 'white', -}) -const wallMaterial = new MeshStandardNodeMaterial({ - color: 'white', - roughness: 1, - metalness: 0, -}) +interface WallMaterials { + visible: MeshStandardNodeMaterial + invisible: MeshStandardNodeMaterial + materialHash: string +} + +const wallMaterialCache = new Map() + +function getMaterialHash(wallNode: WallNode): string { + if (!wallNode.material) return 'none' + const mat = wallNode.material + if (mat.preset && mat.preset !== 'custom') { + return `preset-${mat.preset}` + } + if (mat.properties) { + return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}` + } + return 'default' +} + +const presetColors = { + white: '#ffffff', + brick: '#8b4513', + concrete: '#808080', + wood: '#deb887', + glass: '#87ceeb', + metal: '#c0c0c0', + plaster: '#f5f5dc', + tile: '#dcdcdc', + marble: '#f5f5f5', +} as const + +function getPresetColor(preset: string): string { + return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' +} + +function getMaterialsForWall(wallNode: WallNode): WallMaterials { + const cacheKey = wallNode.id + const materialHash = getMaterialHash(wallNode) + + const existing = wallMaterialCache.get(cacheKey) + if (existing && existing.materialHash === materialHash) { + return existing + } + + if (existing) { + existing.visible.dispose() + existing.invisible.dispose() + } + + let userColor = '#ffffff' + if (wallNode.material?.properties?.color) { + userColor = wallNode.material.properties.color + } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { + userColor = getPresetColor(wallNode.material.preset) + } + + const visibleMat = new MeshStandardNodeMaterial({ + color: userColor, + roughness: 1, + metalness: 0, + }) + + const invisibleMat = new MeshStandardNodeMaterial({ + transparent: true, + opacityNode: mix(float(0.0), float(0.24), dotPattern()), + color: userColor, + depthWrite: false, + emissive: userColor, + }) + + const result: WallMaterials = { visible: visibleMat, invisible: invisibleMat, materialHash } + wallMaterialCache.set(cacheKey, result) + return result +} + +function getWallHideState(wallNode: WallNode, wallMesh: Mesh, wallMode: string, cameraDir: Vector3): boolean { + let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior' + + if (wallMode === 'up') { + hideWall = false + } else if (wallMode === 'down') { + hideWall = true + } else { + wallMesh.getWorldDirection(v) + if (v.dot(cameraDir) < 0) { + if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') { + hideWall = true + } + } else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') { + hideWall = true + } + } + + return hideWall +} export const WallCutout = () => { const lastCameraPosition = useRef(new Vector3()) @@ -52,6 +130,7 @@ export const WallCutout = () => { const lastUpdateTime = useRef(0) const lastWallMode = useRef(useViewer.getState().wallMode) const lastNumberOfWalls = useRef(0) + const lastWallMaterials = useRef>(new Map()) useFrame(({ camera, clock }) => { const wallMode = useViewer.getState().wallMode @@ -60,54 +139,67 @@ export const WallCutout = () => { camera.getWorldDirection(tmpVec) tmpVec.add(currentCameraPosition) - // Throttle: only update if camera moved significantly AND enough time passed const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) const timeSinceUpdate = currentTime - lastUpdateTime.current - // Update if moved > 0.5m OR direction changed > 0.3 AND at least 100ms passed - if ( + const shouldUpdate = ((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) || lastWallMode.current !== wallMode || sceneRegistry.byType.wall.size !== lastNumberOfWalls.current - ) { - // Camera has moved, update cutout logic here - // Update last known positions and time + const walls = sceneRegistry.byType.wall + const currentWallIds = new Set() + + walls.forEach((wallId) => { + const wallMesh = sceneRegistry.nodes.get(wallId) + if (!wallMesh) return + const wallNode = useScene.getState().nodes[wallId as WallNode['id']] + if (!wallNode || wallNode.type !== 'wall') return + + currentWallIds.add(wallId) + + const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) + + if (shouldUpdate) { + const materials = getMaterialsForWall(wallNode) + ;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible + } else { + const currentMaterial = (wallMesh as Mesh).material + const materials = wallMaterialCache.get(wallId) + if (!materials || currentMaterial !== (hideWall ? materials.invisible : materials.visible)) { + const newMaterials = getMaterialsForWall(wallNode) + ;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible + } + } + }) + + if (shouldUpdate) { lastCameraPosition.current.copy(currentCameraPosition) lastCameraTarget.current.copy(tmpVec) lastUpdateTime.current = currentTime camera.getWorldDirection(u) - const walls = sceneRegistry.byType.wall - walls.forEach((wallId) => { - const wallMesh = sceneRegistry.nodes.get(wallId) - if (!wallMesh) return - const wallNode = useScene.getState().nodes[wallId as WallNode['id']] - if (!wallNode || wallNode.type !== 'wall') return - let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior' + if (lastWallMode.current !== wallMode) { + wallMaterialCache.clear() + } - if (wallMode === 'up') { - hideWall = false - } else if (wallMode === 'down') { - hideWall = true - } else { - wallMesh.getWorldDirection(v) - if (v.dot(u) < 0) { - // Front side - if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') { - hideWall = true - } - } else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') { - // Back side - hideWall = true - } + for (const [wallId, mats] of lastWallMaterials.current) { + if (!currentWallIds.has(wallId)) { + mats.visible.dispose() + mats.invisible.dispose() + wallMaterialCache.delete(wallId) } - ;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial - }) + } + + lastWallMaterials.current.clear() + for (const [wallId, mats] of wallMaterialCache) { + lastWallMaterials.current.set(wallId, mats) + } + lastWallMode.current = wallMode lastNumberOfWalls.current = sceneRegistry.byType.wall.size } }) return null -} +} \ No newline at end of file From 0be6d84c9888f0775532748231a6a20e43b07fcf Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 30 Mar 2026 20:29:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=E5=AE=8C=E5=96=84=E6=9D=90?= =?UTF-8?q?=E8=B4=A8=E7=B3=BB=E7=BB=9F=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 node-actions.ts 中 forEach 返回值 lint 警告 - 更新材质 schema 定义和渲染器 - wall-cutout.tsx 材质颜色保留修复 --- packages/core/src/schema/material.ts | 90 ++++++++++++++++--- .../core/src/store/actions/node-actions.ts | 12 ++- packages/core/src/utils/clone-scene-graph.ts | 4 +- .../src/components/editor/export-manager.tsx | 2 +- .../src/components/tools/wall/wall-tool.tsx | 7 +- .../renderers/ceiling/ceiling-renderer.tsx | 7 +- .../roof-segment/roof-segment-renderer.tsx | 2 +- .../renderers/roof/roof-renderer.tsx | 9 +- .../renderers/slab/slab-renderer.tsx | 9 +- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 1 + packages/viewer/src/lib/materials.ts | 5 +- .../viewer/src/systems/wall/wall-cutout.tsx | 14 ++- 12 files changed, 129 insertions(+), 33 deletions(-) diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index a43d5edc..fe9a7a80 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -38,16 +38,86 @@ export const MaterialSchema = z.object({ 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' }, + 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 { diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index e162a873..e0cce687 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -104,11 +104,17 @@ export const updateNodesAction = ( }) // Collect all IDs that need to be marked dirty - updates.forEach((u) => idsToMarkDirty.add(u.id)) - parentsToUpdate.forEach((pId) => idsToMarkDirty.add(pId)) + for (const u of updates) { + idsToMarkDirty.add(u.id) + } + for (const pId of parentsToUpdate) { + idsToMarkDirty.add(pId) + } // Add to pending updates set - idsToMarkDirty.forEach((id) => pendingUpdates.add(id)) + for (const id of idsToMarkDirty) { + pendingUpdates.add(id) + } // Cancel any pending RAF and schedule a new one if (pendingRafId !== null) { diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 2eb3ff39..e4aa55b2 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -52,9 +52,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { // Remap children array (walls, levels, buildings, sites, items can have children) if ('children' in clonedNode && Array.isArray(clonedNode.children)) { - ;(clonedNode as Record).children = ( - clonedNode.children as string[] - ) + ;(clonedNode as Record).children = (clonedNode.children as string[]) .map((childId) => idMap.get(childId)) .filter((id): id is string => id !== undefined) } diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 4bac49cb..50f1025b 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -4,8 +4,8 @@ import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' -import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' export function ExportManager() { const scene = useThree((state) => state.scene) diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index 8ec3169a..1ecf6576 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -6,7 +6,12 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' -import { createWallOnCurrentLevel, snapWallDraftPoint, WALL_MIN_LENGTH, type WallPlanPoint } from './wall-drafting' +import { + createWallOnCurrentLevel, + snapWallDraftPoint, + WALL_MIN_LENGTH, + type WallPlanPoint, +} from './wall-drafting' const WALL_HEIGHT = 2.5 diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index e1d2717c..4e79c3c0 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -3,7 +3,7 @@ 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 { DEFAULT_CEILING_MATERIAL } from '../../../lib/materials' import { NodeRenderer } from '../node-renderer' const gridScale = 5 @@ -46,7 +46,10 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const color = props?.color || '#999999' return createCeilingMaterials(color) } - return { topMaterial: createCeilingMaterials().topMaterial, bottomMaterial: DEFAULT_CEILING_MATERIAL } + return { + topMaterial: createCeilingMaterials().topMaterial, + bottomMaterial: DEFAULT_CEILING_MATERIAL, + } }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index 92ffed90..327935ce 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -35,4 +35,4 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { ) -} \ No newline at end of file +} diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index e5c88b51..c89174f0 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' 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 { createMaterial } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -31,12 +31,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { visible={node.visible} {...handlers} > - + diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f33b6ec0..0abfbdc4 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -18,7 +18,14 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( - + ) diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 28578c76..040beaf7 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -36,4 +36,5 @@ const useGLTFKTX2 = (path: string): ReturnType => { loader.setMeshoptDecoder(MeshoptDecoder) }) } + export { useGLTFKTX2 } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 97e5b46c..369ec7e0 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -34,7 +34,10 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat return threeMaterial } -export function createDefaultMaterial(color: string = '#ffffff', roughness: number = 0.9): THREE.MeshStandardMaterial { +export function createDefaultMaterial( + color: string = '#ffffff', + roughness: number = 0.9, +): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ color, roughness, diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 6568d471..0b50da19 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -103,7 +103,12 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { return result } -function getWallHideState(wallNode: WallNode, wallMesh: Mesh, wallMode: string, cameraDir: Vector3): boolean { +function getWallHideState( + wallNode: WallNode, + wallMesh: Mesh, + wallMode: string, + cameraDir: Vector3, +): boolean { let hideWall = wallNode.frontSide === 'interior' && wallNode.backSide === 'interior' if (wallMode === 'up') { @@ -167,7 +172,10 @@ export const WallCutout = () => { } else { const currentMaterial = (wallMesh as Mesh).material const materials = wallMaterialCache.get(wallId) - if (!materials || currentMaterial !== (hideWall ? materials.invisible : materials.visible)) { + if ( + !materials || + currentMaterial !== (hideWall ? materials.invisible : materials.visible) + ) { const newMaterials = getMaterialsForWall(wallNode) ;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible } @@ -202,4 +210,4 @@ export const WallCutout = () => { } }) return null -} \ No newline at end of file +} From f7a024d5f0e5b83a33f0712aee4934a75da06ac9 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 30 Mar 2026 21:06:49 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20remove=20AGENTS.md=20=E2=80=94=20n?= =?UTF-8?q?ot=20intended=20for=20the=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 187 ------------------------------------------------------ 1 file changed, 187 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c290b244..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,187 +0,0 @@ -# Pascal Editor — Architecture - -## Project Structure - -Monorepo managed with Turborepo. Packages are shared libraries; apps are deployable applications. - -``` -apps/ - editor/ # Main Next.js app (editor + public routes) -packages/ - core/ # Scene schema, state, systems, spatial logic - viewer/ # 3D canvas component (React Three Fiber) - ui/ # Shared React UI components -``` - ---- - -## packages/core - -Central library — no UI, no rendering. Everything else depends on it. - -- **schema/** — TypeScript types for all node types (`Wall`, `Slab`, `Door`, `Item`, etc.) -- **store/** — Zustand scene store (`useScene`) with undo/redo via Zundo -- **systems/** — Per-element business logic: geometry generation, constraints (`WallSystem`, `SlabSystem`, `DoorSystem`, …) -- **events/** — Typed event bus for node changes -- **hooks/** — `useRegistry` (node ID → THREE.Object3D), `useSpatialGrid` (2D spatial index) -- **lib/** — Space detection, asset storage, polygon utilities - -Node storage is a flat dictionary (`nodes: Record`). Systems are pure logic that runs in the render loop; they read nodes and write back derived geometry. - ---- - -## packages/viewer - -3D canvas component — presentation only, no editor concerns. - -- **components/viewer/** — Root `` canvas, camera, lights, post-processing, selection manager -- **components/renderers/** — One renderer per node type (`WallRenderer`, `SlabRenderer`, …), dispatched by `NodeRenderer` → `SceneRenderer` -- **systems/** — Viewer-specific systems: `LevelSystem` (stacked/exploded/solo), `WallCutout`, `ZoneSystem`, `InteractiveSystem` -- **store/** — `useViewer`: selection path, camera mode, level mode, wall mode, theme, display toggles - -The viewer accepts external props and callbacks (`onSelect`, `onExport`, children) to expose control points. It must not import anything from `apps/editor`. - ---- - -## apps/editor - -Next.js 16 app. Composes `@pascal-app/viewer` and `@pascal-app/core` into a full editing experience. - -- **app/editor/[projectId]/** — Main editor route -- **app/viewer/[id]/** — Read-only preview route -- **store/use-editor.tsx** — `useEditor`: phase (`site | structure | furnish`), mode (`select | edit | delete | build`), active tool -- **components/tools/** — One component per tool, coordinated by `ToolManager` -- **components/systems/** — Editor-side systems that integrate with viewer (e.g. space detection for cutaway) -- **components/editor/** — Camera controls, export, menus, panels - ---- - -## Data Flow - -``` -User input (pointer/keyboard) - → Tool component (apps/editor/components/tools/) - → useScene mutations - → Core systems recompute geometry - → Renderers re-render THREE meshes - → useViewer updates selection/hover -``` - ---- - -## Key Conventions - -- **Flat nodes** — All scene nodes live in a single flat record; hierarchy is expressed via `parentId`. -- **System/renderer split** — Systems own logic; renderers own geometry and material. Never mix. -- **Viewer isolation** — `@pascal-app/viewer` must never import from `apps/editor`. Editor-specific behaviour (tools, systems, selection) is injected as children or props. -- **Registry pattern** — `useRegistry()` maps node IDs to live THREE objects without tree traversal. -- **Spatial grid** — 2D grid for fast wall/zone neighbourhood queries; avoid brute-force iteration. -- **Node creation** — Always use `NodeType.parse({…})` then `createNode(node, parentId)`. Never construct raw node objects. - ---- - -## Tech Stack - -| Layer | Technology | -|---|---| -| 3D | Three.js (WebGPU), React Three Fiber | -| Framework | Next.js 16, React 19 | -| 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 -```