diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 76156f06..20cbc609 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -2458,7 +2458,7 @@ - `BuildingBlockProps` (interface): interface BuildingBlockProps — ⚠ undocumented - `BuildingFacade` (type): type BuildingFacade = "front" | "back" | "left" | "right" | "roof" — ⚠ undocumented - `BuildingKitRenderer` (interface): interface BuildingKitRenderer — ⚠ undocumented -- `BuildingMaterialPalette` (interface): interface BuildingMaterialPalette — ⚠ undocumented +- `BuildingMaterialPalette` (type): type BuildingMaterialPalette = Partial> — Per part kind: a hex colour, or a colour plus tiled PBR maps (`BuildingSurfaceMaterial`). - `BuildingPartKind` (type): type BuildingPartKind = | "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner" — ⚠ undocumented - `BuildingPartPlacement` (interface): interface BuildingPartPlacement — ⚠ undocumented - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented @@ -2490,6 +2490,12 @@ - `listBuildingKits` (function): function listBuildingKits(): string[] — Every registered kit id — the editor's kit picker and diagnostics read this. - `registerBuildingKit` (function): function registerBuildingKit(kit: BuildingKit): void — Registers a kit under its own `id`, replacing any kit already registered under that name. +## @jgengine/shell/structures/buildingSurface + +- `TexturedBuildingSurface` (type): type TexturedBuildingSurface = BuildingSurfaceMaterial & { maps: BuildingSurfaceMaps } — A surface that names at least one map URL. +- `surfaceHasMaps` (function): function surfaceHasMaps(surface: BuildingSurfaceMaterial | undefined): surface is TexturedBuildingSurface — True when the surface names at least one map URL, so the batch needs the textured path. +- `surfaceKey` (function): function surfaceKey(surface: BuildingSurfaceMaterial | undefined): string — A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). + ## @jgengine/shell/terrain - `CarvedTerrain` (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, surfaceMaterial, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element — Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted. diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index ad9992c0..6703d543 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -2772,7 +2772,7 @@ ## @jgengine/core/world/buildings -- `BUILDING_STYLE_PALETTES` (const): const BUILDING_STYLE_PALETTES: Record — ⚠ undocumented +- `BUILDING_STYLE_PALETTES` (const): const BUILDING_STYLE_PALETTES: Record> — ⚠ undocumented - `BUILDING_STYLE_WALL_TONES` (const): const BUILDING_STYLE_WALL_TONES: Record — Per-style facade-tone family: the wall colours a district spreads across its buildings so neighbours differ in hue and value while the block stays one palette. Index 0 is the palette's own `wall`; the rest fan warmer/cooler and lighter/darker around it. - `BuildingCellRef` (interface): interface BuildingCellRef — ⚠ undocumented - `BuildingConfig` (interface): interface BuildingConfig — ⚠ undocumented @@ -2781,7 +2781,7 @@ - `BuildingGridConfig` (interface): interface BuildingGridConfig — ⚠ undocumented - `BuildingKitSlot` (interface): interface BuildingKitSlot — ⚠ undocumented - `BuildingLot` (interface): interface BuildingLot — ⚠ undocumented -- `BuildingPalette` (type): type BuildingPalette = Record — ⚠ undocumented +- `BuildingPalette` (type): type BuildingPalette = Record — ⚠ undocumented - `BuildingPaletteOverrides` (type): type BuildingPaletteOverrides = Partial — ⚠ undocumented - `BuildingPartKind` (type): type BuildingPartKind = | "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner" — ⚠ undocumented - `BuildingPartPlacement` (interface): interface BuildingPartPlacement — ⚠ undocumented @@ -2789,14 +2789,19 @@ - `BuildingProbabilities` (interface): interface BuildingProbabilities — ⚠ undocumented - `BuildingSeed` (type): type BuildingSeed = number | string — ⚠ undocumented - `BuildingStyle` (type): type BuildingStyle = | "generic" | "capital" | "village" | "desert" | "industrial" | "coastal" | "neon" | "ruin" | "frontier" | "aerial" — ⚠ undocumented +- `BuildingSurface` (type): type BuildingSurface = string | BuildingSurfaceMaterial — What a building part kind is painted with: a hex colour, or a colour plus tiled PBR maps. +- `BuildingSurfaceMaps` (interface): interface BuildingSurfaceMaps — URLs of the PBR maps a building surface tiles; `buildMaterialCatalog(...).resolve(id)!.maps` fits directly. +- `BuildingSurfaceMaterial` (interface): interface BuildingSurfaceMaterial — A textured building surface: a base colour plus optional tiled PBR maps. With `maps`, `color` multiplies the albedo (leave it light to show the map as authored); without, it is the flat colour the part has always had. - `BuildingVariantCounts` (interface): interface BuildingVariantCounts — ⚠ undocumented - `DEFAULT_BUILDING_CONFIG` (const): const DEFAULT_BUILDING_CONFIG: BuildingConfig — ⚠ undocumented - `DEFAULT_BUILDING_STYLE` (const): const DEFAULT_BUILDING_STYLE: BuildingStyle — ⚠ undocumented - `GeneratedBuilding` (interface): interface GeneratedBuilding — ⚠ undocumented - `Vec3` (type): type Vec3 = readonly [number, number, number] — ⚠ undocumented +- `buildingSurfaceColor` (function): function buildingSurfaceColor(surface: BuildingSurface | undefined, fallback = "#808080"): string — The flat colour of a surface: the hex itself, or a material's `color`, else `fallback`. - `createBuildingConfig` (function): function createBuildingConfig(input: BuildingConfigInput = {}): BuildingConfig — Fill a partial building description out to a complete {@link BuildingConfig}, clamping counts and probabilities. Use it to inspect or adjust the resolved shape before generating. - `generateBuilding` (function): function generateBuilding(input: BuildingConfigInput = {}): GeneratedBuilding — Generate one building as a flat list of placed facade parts: bays × floors per facade, a ground row, corners, and a roof, with every decorative part rolled from `probabilities` and every part tagged with a `kit` slot the renderer binds to a model (see `world/buildingKit`). Deterministic from `seed`. - `generateBuildingDistrict` (function): function generateBuildingDistrict(config: BuildingGridConfig): GeneratedBuilding[] — Generate a grid of buildings on lots sized by `lotSize` and separated by `streetWidth`, each with its floor count rolled from `floorRange`. +- `resolveBuildingSurface` (function): function resolveBuildingSurface(surface: BuildingSurface): BuildingSurfaceMaterial — Normalises a surface to its material form so a renderer reads one shape. ## @jgengine/core/world/carve diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b184a16..e4342d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,10 @@ between (`--json` for structured output). ### Added -- `sky({ sun: { azimuth, elevation } })` places the dome sun and the sky-owned sun light by compass bearing and height; under `timeOfDay` the day arc swings around that noon position. Unset, the shell derives it from the first authored `lighting.directional` light so the dome glow agrees with the shadows. Editor lighting panel gains bearing/height sliders (`sunAzimuth`/`sunElevation` on the scene document). -- `warnOnce` in `@jgengine/core/devtools/warnOnce`: dev-only, once-per-site console warnings. Used for a `sky.radius` at or past the camera far plane (the dome depth-clips to black) and for `zenithColor`/`horizonColor` under `timeOfDay` (they only set the noon keyframe). +- Building palettes take textured surfaces: a `BuildingPalette` part (and `BuildingKitPart.material`) may be `{ color?, maps?, repeat?, roughness?, metalness? }` with `maps` straight from `buildMaterialCatalog(...).resolve(id)!.maps`, not only a hex colour. Generated facade boxes and kit models tile the PBR maps per slot; unbound kinds keep their flat colour. `buildingSurfaceColor` / `resolveBuildingSurface` read either form. ### Fixed -- Time-of-day and biome-driven sky domes now draw the sun disc and glow, tracking the sun through the day; previously only the fixed-preset dome had one. - - Separate SDK publication validation from external Games quality checks while retaining the full local and main-branch gates. - Correct foot-IK endpoint recomputation after hip rotation; verify reachable targets and clamping without changing bone lengths. diff --git a/apps/dev/public/materials/ambientcg-plaster001/color.jpg b/apps/dev/public/materials/ambientcg-plaster001/color.jpg new file mode 100644 index 00000000..54b8d7bb Binary files /dev/null and b/apps/dev/public/materials/ambientcg-plaster001/color.jpg differ diff --git a/apps/dev/public/materials/ambientcg-plaster001/displacement.jpg b/apps/dev/public/materials/ambientcg-plaster001/displacement.jpg new file mode 100644 index 00000000..a318d062 Binary files /dev/null and b/apps/dev/public/materials/ambientcg-plaster001/displacement.jpg differ diff --git a/apps/dev/public/materials/ambientcg-plaster001/normal.jpg b/apps/dev/public/materials/ambientcg-plaster001/normal.jpg new file mode 100644 index 00000000..f1baa32d Binary files /dev/null and b/apps/dev/public/materials/ambientcg-plaster001/normal.jpg differ diff --git a/apps/dev/public/materials/ambientcg-plaster001/roughness.jpg b/apps/dev/public/materials/ambientcg-plaster001/roughness.jpg new file mode 100644 index 00000000..fbcd590d Binary files /dev/null and b/apps/dev/public/materials/ambientcg-plaster001/roughness.jpg differ diff --git a/apps/dev/public/materials/ambientcg-roofingtiles001/color.jpg b/apps/dev/public/materials/ambientcg-roofingtiles001/color.jpg new file mode 100644 index 00000000..f4195b22 Binary files /dev/null and b/apps/dev/public/materials/ambientcg-roofingtiles001/color.jpg differ diff --git a/apps/dev/public/materials/ambientcg-roofingtiles001/displacement.jpg b/apps/dev/public/materials/ambientcg-roofingtiles001/displacement.jpg new file mode 100644 index 00000000..4904060d Binary files /dev/null and b/apps/dev/public/materials/ambientcg-roofingtiles001/displacement.jpg differ diff --git a/apps/dev/public/materials/ambientcg-roofingtiles001/normal.jpg b/apps/dev/public/materials/ambientcg-roofingtiles001/normal.jpg new file mode 100644 index 00000000..846a2e41 Binary files /dev/null and b/apps/dev/public/materials/ambientcg-roofingtiles001/normal.jpg differ diff --git a/apps/dev/public/materials/ambientcg-roofingtiles001/roughness.jpg b/apps/dev/public/materials/ambientcg-roofingtiles001/roughness.jpg new file mode 100644 index 00000000..82f2bda6 Binary files /dev/null and b/apps/dev/public/materials/ambientcg-roofingtiles001/roughness.jpg differ diff --git a/packages/core/src/world/buildingGenerator.ts b/packages/core/src/world/buildingGenerator.ts index d7eba2ee..5cdda9ad 100644 --- a/packages/core/src/world/buildingGenerator.ts +++ b/packages/core/src/world/buildingGenerator.ts @@ -8,7 +8,7 @@ */ import { registerAssetGenerator, partsBounds, type GeneratedAsset, type GeneratedPart } from "../scene/assetGenerator"; import type { ParamSchema, ParsedParams } from "../scene/sceneKinds"; -import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_CONFIG, generateBuilding, resolveBuildingPalette, type BuildingStyle } from "./buildings"; +import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_CONFIG, buildingSurfaceColor, generateBuilding, resolveBuildingPalette, type BuildingStyle } from "./buildings"; /** The generator id a catalog entry / placed marker references. */ export const BUILDING_GENERATOR_ID = "building"; @@ -51,7 +51,7 @@ export function generateBuildingAsset(params: ParsedParams, seed: string): Gener position: part.position, size: part.scale, rotationY: part.rotationY, - color: palette[part.kind], + color: buildingSurfaceColor(palette[part.kind]), })); return { parts, bounds: partsBounds(parts) }; } diff --git a/packages/core/src/world/buildingKit.ts b/packages/core/src/world/buildingKit.ts index 82829f53..ae97920e 100644 --- a/packages/core/src/world/buildingKit.ts +++ b/packages/core/src/world/buildingKit.ts @@ -3,7 +3,7 @@ * as an instanced asset kit instead of untextured blocks. Pure serializable data — core resolves * bindings, the renderer loads them. */ -import type { BuildingKitSlot, BuildingPartKind, BuildingVariantCounts, Vec3 } from "./buildings"; +import type { BuildingKitSlot, BuildingPartKind, BuildingSurfaceMaterial, BuildingVariantCounts, Vec3 } from "./buildings"; /** * How a kit model's native bounds are mapped onto the slot box `BuildingPartPlacement.scale` @@ -34,6 +34,8 @@ export interface BuildingKitPart { /** Multiplier applied after `fit`. */ scale?: Vec3; tint?: string; + /** Tiled PBR maps applied over the model's own materials; `color` tints, `repeat` sets the tiling. */ + material?: BuildingSurfaceMaterial; /** * Quarter-turn the model when its long horizontal axis disagrees with the slot's, so a panel * authored running along Z still tiles a bay that runs along X. Default true — modular kits do not diff --git a/packages/core/src/world/buildings.test.ts b/packages/core/src/world/buildings.test.ts index 051877ee..729cab4c 100644 --- a/packages/core/src/world/buildings.test.ts +++ b/packages/core/src/world/buildings.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { BUILDING_STYLE_PALETTES, + buildingSurfaceColor, createBuildingConfig, createBuildingGrid, generateBuilding, generateBuildingDistrict, resolveBuildingPalette, + resolveBuildingSurface, } from "./buildings"; describe("building palettes", () => { @@ -24,6 +26,17 @@ describe("building palettes", () => { expect(() => resolveBuildingPalette("aerodrome" as never)).toThrow(/Valid styles:/); }); + test("a palette part may be a textured surface, and colour readers still see a hex", () => { + const wall = { color: "#d9cbb0", maps: { color: "/materials/plaster/color.jpg" }, repeat: [2, 1.5] as const }; + const palette = resolveBuildingPalette("coastal", { wall }); + expect(palette.wall).toBe(wall); + expect(buildingSurfaceColor(palette.wall)).toBe("#d9cbb0"); + expect(buildingSurfaceColor({ maps: {} }, "#123456")).toBe("#123456"); + expect(buildingSurfaceColor(undefined, "#abcdef")).toBe("#abcdef"); + expect(resolveBuildingSurface("#ffffff")).toEqual({ color: "#ffffff" }); + expect(resolveBuildingSurface(wall)).toBe(wall); + }); + test("every building style palette has a distinct wall color", () => { const walls = Object.values(BUILDING_STYLE_PALETTES).map((palette) => palette.wall); expect(new Set(walls).size).toBe(walls.length); diff --git a/packages/core/src/world/buildings.ts b/packages/core/src/world/buildings.ts index d04e3b32..a2189cab 100644 --- a/packages/core/src/world/buildings.ts +++ b/packages/core/src/world/buildings.ts @@ -165,9 +165,51 @@ export const DEFAULT_BUILDING_CONFIG: BuildingConfig = { variants: DEFAULT_VARIANTS, }; -export type BuildingPalette = Record; +/** URLs of the PBR maps a building surface tiles; `buildMaterialCatalog(...).resolve(id)!.maps` fits directly. */ +export interface BuildingSurfaceMaps { + color?: string; + normal?: string; + roughness?: string; + ao?: string; + metalness?: string; +} + +/** + * A textured building surface: a base colour plus optional tiled PBR maps. With `maps`, `color` + * multiplies the albedo (leave it light to show the map as authored); without, it is the flat + * colour the part has always had. + */ +export interface BuildingSurfaceMaterial { + /** Base colour; the style's colour for that part kind when unset. */ + color?: string; + maps?: BuildingSurfaceMaps; + /** + * Texture tiles per part slot along [x, y]; a number tiles both. Slots within one building share + * a size, so one repeat per kind is uniform there; it drifts across buildings with a different + * `bayWidth`. Default 1. + */ + repeat?: number | readonly [number, number]; + roughness?: number; + metalness?: number; +} + +/** What a building part kind is painted with: a hex colour, or a colour plus tiled PBR maps. */ +export type BuildingSurface = string | BuildingSurfaceMaterial; + +export type BuildingPalette = Record; export type BuildingPaletteOverrides = Partial; +/** The flat colour of a surface: the hex itself, or a material's `color`, else `fallback`. */ +export function buildingSurfaceColor(surface: BuildingSurface | undefined, fallback = "#808080"): string { + if (surface === undefined) return fallback; + return typeof surface === "string" ? surface : (surface.color ?? fallback); +} + +/** Normalises a surface to its material form so a renderer reads one shape. */ +export function resolveBuildingSurface(surface: BuildingSurface): BuildingSurfaceMaterial { + return typeof surface === "string" ? { color: surface } : surface; +} + export type BuildingStyle = | "generic" | "capital" @@ -182,7 +224,7 @@ export type BuildingStyle = export const DEFAULT_BUILDING_STYLE: BuildingStyle = "generic"; -export const BUILDING_STYLE_PALETTES: Record = { +export const BUILDING_STYLE_PALETTES: Record> = { generic: { wall: "#83766a", window: "#8ecae6", @@ -351,7 +393,7 @@ export const BUILDING_STYLE_WALL_TONES: Record export function resolveBuildingWallTones(style: BuildingStyle = DEFAULT_BUILDING_STYLE): readonly string[] { const tones = BUILDING_STYLE_WALL_TONES[style] as readonly string[] | undefined; if (tones !== undefined && tones.length > 0) return tones; - return [resolveBuildingPalette(style).wall]; + return [buildingSurfaceColor(resolveBuildingPalette(style).wall)]; } /** @internal */ diff --git a/packages/shell/src/scene/CityRenderer.tsx b/packages/shell/src/scene/CityRenderer.tsx index c9a7f42b..fbcbbb06 100644 --- a/packages/shell/src/scene/CityRenderer.tsx +++ b/packages/shell/src/scene/CityRenderer.tsx @@ -42,6 +42,7 @@ import { import { zoneBand, zoneMetric, type CityTreeSpecies, type CityPieceRole, type CityZoneBand } from "@jgengine/core/world/cityContent"; import { generateBuilding, + buildingSurfaceColor, resolveBuildingPalette, resolveBuildingWallTones, type BuildingPalette, @@ -390,10 +391,10 @@ function unitGeometries() { const ROLE_JITTER: Record = { wall: 1, roof: 0.5, trim: 0.35, accent: 0.6 }; function roleColor(palette: BuildingPalette, role: CityPieceRole): string { - if (role === "wall") return palette.wall; - if (role === "roof") return palette.roof; - if (role === "trim") return palette.corner; - return palette.awning; + if (role === "wall") return buildingSurfaceColor(palette.wall); + if (role === "roof") return buildingSurfaceColor(palette.roof); + if (role === "trim") return buildingSurfaceColor(palette.corner); + return buildingSurfaceColor(palette.awning); } interface DetailBuilding { @@ -651,7 +652,7 @@ function OneCity({ object, context }: { object: SceneKindObject; context: SceneK return mesh; }; const plain = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0.04 }); - const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: palette.window }); + const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: buildingSurfaceColor(palette.window) }); const meshes = [ makeMesh("box", units.box, plain), makeMesh("banded", units.box, banded), diff --git a/packages/shell/src/structures/BuildingKitBatch.tsx b/packages/shell/src/structures/BuildingKitBatch.tsx index 6707658e..4c51cdba 100644 --- a/packages/shell/src/structures/BuildingKitBatch.tsx +++ b/packages/shell/src/structures/BuildingKitBatch.tsx @@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef } from "react"; import { useLoader } from "@react-three/fiber"; import * as THREE from "three"; +import type { BuildingSurfaceMaterial } from "@jgengine/core/world/buildings"; + +import type { MaterialOverrideTextures } from "../materialOverride"; import { sharedGltfLoader } from "../render/modelLoad"; import { buildScatterModelSources, @@ -13,6 +16,7 @@ import { measureBuildingKitModel, type BuildingKitInstance, } from "./buildingKitFit"; +import { applyBuildingSurface, surfaceKey, useBuildingSurfaceTextures } from "./buildingSurface"; function KitSourceInstances({ source, @@ -47,16 +51,39 @@ function KitSourceInstances({ ); } -function tintOne(material: THREE.Material, color: THREE.Color): THREE.Material { +function styleOne( + material: THREE.Material, + tint: THREE.Color | undefined, + surface: BuildingSurfaceMaterial | undefined, + textures: MaterialOverrideTextures | undefined, +): THREE.Material { const clone = material.clone(); const tintable = clone as THREE.Material & { color?: THREE.Color }; - if (tintable.color !== undefined) tintable.color.copy(color); + if (tint !== undefined && tintable.color !== undefined) tintable.color.copy(tint); + if (surface !== undefined && (clone as THREE.MeshStandardMaterial).isMeshStandardMaterial === true) { + applyBuildingSurface(clone as THREE.MeshStandardMaterial, surface, textures); + } return clone; } -function tintMaterial(material: THREE.Material | THREE.Material[], tint: string): THREE.Material | THREE.Material[] { - const color = new THREE.Color(tint); - return Array.isArray(material) ? material.map((entry) => tintOne(entry, color)) : tintOne(material, color); +function styleMaterial( + material: THREE.Material | THREE.Material[], + tint: string, + surface: BuildingSurfaceMaterial | undefined, + textures: MaterialOverrideTextures | undefined, +): THREE.Material | THREE.Material[] { + const color = tint === "" ? undefined : new THREE.Color(tint); + return Array.isArray(material) + ? material.map((entry) => styleOne(entry, color, surface, textures)) + : styleOne(material, color, surface, textures); +} + +/** Every instance of one model that shares a tint and surface, so they can share one styled material. */ +interface KitStyleGroup { + key: string; + tint: string; + surface: BuildingSurfaceMaterial | undefined; + matrices: THREE.Matrix4[]; } function disposeMaterial(material: THREE.Material | THREE.Material[]): void { @@ -83,42 +110,46 @@ export function BuildingKitBatch({ url, instances }: BuildingKitBatchProps) { const bounds = useMemo(() => measureBuildingKitModel(root), [root]); const groups = useMemo(() => { - const byTint = new Map(); + const byKey = new Map(); for (const instance of instances) { const matrix = composeBuildingKitMatrix(instance, bounds, new THREE.Matrix4()); const tint = instance.part.tint ?? ""; - const bucket = byTint.get(tint); - if (bucket === undefined) byTint.set(tint, [matrix]); - else bucket.push(matrix); + const surface = instance.part.material; + const key = `${tint}|${surfaceKey(surface)}`; + const bucket = byKey.get(key); + if (bucket === undefined) byKey.set(key, { key, tint, surface, matrices: [matrix] }); + else bucket.matrices.push(matrix); } - return [...byTint.entries()]; + return [...byKey.values()]; }, [instances, bounds]); - const tinted = useMemo(() => { - const map = new Map(); - for (const [tint] of groups) { - if (tint === "") continue; - map.set( - tint, - sources.map((source) => tintMaterial(source.material, tint)), - ); - } - return map; - }, [groups, sources]); - useEffect(() => () => tinted.forEach((list) => list.forEach(disposeMaterial)), [tinted]); - return ( <> - {groups.map(([tint, matrices]) => - sources.map((source, index) => ( - - )), + {groups.map((group) => + group.tint === "" && group.surface === undefined ? ( + sources.map((source, index) => ( + + )) + ) : ( + + ), )} ); } + +function StyledKitSources({ sources, group }: { sources: readonly ScatterModelSource[]; group: KitStyleGroup }) { + const textures = useBuildingSurfaceTextures(group.surface); + const styled = useMemo( + () => sources.map((source) => styleMaterial(source.material, group.tint, group.surface, textures)), + [sources, group.tint, group.surface, textures], + ); + useEffect(() => () => styled.forEach(disposeMaterial), [styled]); + return ( + <> + {sources.map((source, index) => ( + + ))} + + ); +} diff --git a/packages/shell/src/structures/GeneratedBuilding.tsx b/packages/shell/src/structures/GeneratedBuilding.tsx index 3034b5f3..30c75644 100644 --- a/packages/shell/src/structures/GeneratedBuilding.tsx +++ b/packages/shell/src/structures/GeneratedBuilding.tsx @@ -1,12 +1,20 @@ import { Suspense, useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; import * as THREE from "three"; -import type { BuildingKitSlot } from "@jgengine/core/world/buildings"; +import { + buildingSurfaceColor, + resolveBuildingSurface, + type BuildingKitSlot, + type BuildingSurface, + type BuildingSurfaceMaterial, +} from "@jgengine/core/world/buildings"; import type { BuildingKit } from "@jgengine/core/world/buildingKit"; +import type { MaterialOverrideTextures } from "../materialOverride"; import { useDisposable } from "../render/useDisposable"; import { BuildingKitBatch } from "./BuildingKitBatch"; import { bucketBuildingParts, normalFor, outwardOffset } from "./buildingKitFit"; +import { applyBuildingSurface, useBuildingSurfaceTextures } from "./buildingSurface"; export type BuildingFacade = "front" | "back" | "left" | "right" | "roof"; export type BuildingPartKind = @@ -39,20 +47,8 @@ export interface GeneratedBuildingData { parts: readonly BuildingPartPlacement[]; } -export interface BuildingMaterialPalette { - wall?: string; - window?: string; - awning?: string; - airConditioner?: string; - clothesline?: string; - storefront?: string; - shutter?: string; - storeSign?: string; - roof?: string; - roofProp?: string; - guardrail?: string; - corner?: string; -} +/** Per part kind: a hex colour, or a colour plus tiled PBR maps (`BuildingSurfaceMaterial`). */ +export type BuildingMaterialPalette = Partial>; export interface BuildingKitRenderer { renderPart?: (part: BuildingPartPlacement) => ReactNode | undefined; @@ -97,7 +93,7 @@ export interface InstancedBuildingsProps { visibleKinds?: readonly BuildingPartKind[]; } -const DEFAULT_PALETTE: Required = { +const DEFAULT_PALETTE: Record = { wall: "#83766a", window: "#8ecae6", awning: "#c2410c", @@ -112,8 +108,13 @@ const DEFAULT_PALETTE: Required = { corner: "#6b6258", }; +function surfaceFor(kind: BuildingPartKind, palette: BuildingMaterialPalette | undefined): BuildingSurfaceMaterial { + const surface = resolveBuildingSurface(palette?.[kind] ?? DEFAULT_PALETTE[kind]); + return surface.color === undefined ? { ...surface, color: DEFAULT_PALETTE[kind] } : surface; +} + function colorFor(kind: BuildingPartKind, palette: BuildingMaterialPalette | undefined): string { - return palette?.[kind] ?? DEFAULT_PALETTE[kind]; + return buildingSurfaceColor(palette?.[kind], DEFAULT_PALETTE[kind]); } function materialFor(part: BuildingPartPlacement, palette: BuildingMaterialPalette | undefined) { @@ -130,33 +131,35 @@ function materialFor(part: BuildingPartPlacement, palette: BuildingMaterialPalet return ; } -function batchMaterialFor(kind: BuildingPartKind, palette: BuildingMaterialPalette | undefined): THREE.Material { - const color = colorFor(kind, palette); - if (kind === "window" || kind === "storefront") { - return new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 }); - } - if (kind === "storeSign") { - return new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 }); - } - if (kind === "roofProp") { - return new THREE.MeshStandardMaterial({ color, roughness: 0.65, metalness: 0.1 }); - } - return new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 }); +function batchMaterialFor( + kind: BuildingPartKind, + surface: BuildingSurfaceMaterial, + textures?: MaterialOverrideTextures, +): THREE.Material { + const color = surface.color ?? DEFAULT_PALETTE[kind]; + const base = + kind === "window" || kind === "storefront" + ? new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 }) + : kind === "storeSign" + ? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 }) + : kind === "roofProp" + ? new THREE.MeshStandardMaterial({ color, roughness: 0.65, metalness: 0.1 }) + : new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 }); + return applyBuildingSurface(base, surface, textures); } -function BuildingKindBatch({ - kind, - matrices, - palette, - geometry, -}: { +interface BuildingKindBatchProps { kind: BuildingPartKind; matrices: readonly THREE.Matrix4[]; palette?: BuildingMaterialPalette; geometry: THREE.BoxGeometry; -}) { +} + +function BuildingKindBatch({ kind, matrices, palette, geometry }: BuildingKindBatchProps) { + const surface = useMemo(() => surfaceFor(kind, palette), [kind, palette]); + const textures = useBuildingSurfaceTextures(surface); const meshRef = useRef(null); - const material = useDisposable(() => batchMaterialFor(kind, palette), [kind, palette]); + const material = useDisposable(() => batchMaterialFor(kind, surface, textures), [kind, surface, textures]); useLayoutEffect(() => { const mesh = meshRef.current; if (mesh === null) return; diff --git a/packages/shell/src/structures/buildingSurface.ts b/packages/shell/src/structures/buildingSurface.ts new file mode 100644 index 00000000..da92befd --- /dev/null +++ b/packages/shell/src/structures/buildingSurface.ts @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useState } from "react"; +import * as THREE from "three"; + +import type { BuildingSurfaceMaps, BuildingSurfaceMaterial } from "@jgengine/core/world/buildings"; + +import type { MaterialOverrideTextures } from "../materialOverride"; + +const SURFACE_ROLES = ["color", "normal", "roughness", "ao", "metalness"] as const; +type SurfaceRole = (typeof SURFACE_ROLES)[number]; + +/** A surface that names at least one map URL. */ +export type TexturedBuildingSurface = BuildingSurfaceMaterial & { maps: BuildingSurfaceMaps }; + +/** True when the surface names at least one map URL, so the batch needs the textured path. */ +export function surfaceHasMaps(surface: BuildingSurfaceMaterial | undefined): surface is TexturedBuildingSurface { + if (surface?.maps === undefined) return false; + return SURFACE_ROLES.some((role) => surface.maps?.[role] !== undefined); +} + +/** A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). */ +export function surfaceKey(surface: BuildingSurfaceMaterial | undefined): string { + if (surface === undefined) return ""; + const repeat = surface.repeat === undefined ? 1 : surface.repeat; + const maps = surface.maps === undefined ? "" : SURFACE_ROLES.map((role) => surface.maps?.[role] ?? "").join(","); + const tiling = typeof repeat === "number" ? String(repeat) : repeat.join("x"); + return `${surface.color ?? ""}|${maps}|${tiling}|${surface.roughness ?? ""}|${surface.metalness ?? ""}`; +} + +function repeatOf(surface: BuildingSurfaceMaterial): [number, number] { + const repeat = surface.repeat ?? 1; + return typeof repeat === "number" ? [repeat, repeat] : [repeat[0], repeat[1]]; +} + +const textureLoader = new THREE.TextureLoader(); +const loads = new Map>(); + +/** One shared decode per URL; a missing file (a pack without `ao.jpg`) resolves to null instead of failing the batch. */ +function loadSurfaceTexture(url: string): Promise { + let pending = loads.get(url); + if (pending === undefined) { + pending = new Promise((resolve) => { + textureLoader.load( + url, + (texture) => resolve(texture), + undefined, + () => resolve(null), + ); + }); + loads.set(url, pending); + } + return pending; +} + +/** + * Loads a surface's PBR maps and returns per-caller clones configured to tile `repeat` times per + * slot, or `undefined` until the first map arrives so the batch draws its flat colour meanwhile. + * Clones share the decoded image, so two kinds using one map at different repeats cost one + * download. Maps whose file is missing are skipped rather than failing the whole surface. + * @internal + */ +export function useBuildingSurfaceTextures( + surface: BuildingSurfaceMaterial | undefined, +): MaterialOverrideTextures | undefined { + const maps = surface?.maps; + const color = maps?.color; + const normal = maps?.normal; + const roughness = maps?.roughness; + const ao = maps?.ao; + const metalness = maps?.metalness; + const urls = useMemo(() => { + const record: Partial> = {}; + if (color !== undefined) record.color = color; + if (normal !== undefined) record.normal = normal; + if (roughness !== undefined) record.roughness = roughness; + if (ao !== undefined) record.ao = ao; + if (metalness !== undefined) record.metalness = metalness; + return record; + }, [color, normal, roughness, ao, metalness]); + const [loaded, setLoaded] = useState> | undefined>(undefined); + useEffect(() => { + const entries = Object.entries(urls) as [SurfaceRole, string][]; + if (entries.length === 0) { + setLoaded(undefined); + return; + } + let cancelled = false; + Promise.all(entries.map(([role, url]) => loadSurfaceTexture(url).then((texture) => [role, texture] as const))).then( + (results) => { + if (cancelled) return; + const record: Partial> = {}; + for (const [role, texture] of results) if (texture !== null) record[role] = texture; + setLoaded(Object.keys(record).length === 0 ? undefined : record); + }, + ); + return () => { + cancelled = true; + }; + }, [urls]); + const [repeatX, repeatY] = surface === undefined ? [1, 1] : repeatOf(surface); + const textures = useMemo(() => { + if (loaded === undefined) return undefined; + const out: MaterialOverrideTextures = {}; + for (const role of SURFACE_ROLES) { + const source = loaded[role]; + if (source === undefined) continue; + const texture = source.clone(); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(repeatX, repeatY); + texture.colorSpace = role === "color" ? THREE.SRGBColorSpace : THREE.NoColorSpace; + texture.needsUpdate = true; + out[role] = texture; + } + return out; + }, [loaded, repeatX, repeatY]); + useEffect( + () => () => { + if (textures === undefined) return; + for (const role of SURFACE_ROLES) textures[role]?.dispose(); + }, + [textures], + ); + return textures; +} + +/** Applies a surface's colour, response, and loaded maps onto a standard or physical material in place. @internal */ +export function applyBuildingSurface( + material: THREE.MeshStandardMaterial, + surface: BuildingSurfaceMaterial, + textures: MaterialOverrideTextures | undefined, +): THREE.MeshStandardMaterial { + if (surface.color !== undefined) material.color.set(surface.color); + if (surface.roughness !== undefined) material.roughness = surface.roughness; + if (surface.metalness !== undefined) material.metalness = surface.metalness; + if (textures !== undefined) { + if (textures.color !== undefined) material.map = textures.color; + if (textures.normal !== undefined) material.normalMap = textures.normal; + if (textures.roughness !== undefined) material.roughnessMap = textures.roughness; + if (textures.ao !== undefined) material.aoMap = textures.ao; + if (textures.metalness !== undefined) material.metalnessMap = textures.metalness; + material.needsUpdate = true; + } + return material; +} diff --git a/scripts/api-doc-baseline.json b/scripts/api-doc-baseline.json index 020755d4..1cbd3d96 100644 --- a/scripts/api-doc-baseline.json +++ b/scripts/api-doc-baseline.json @@ -2762,7 +2762,6 @@ "@jgengine/shell/structures/GeneratedBuilding#BuildingBlockProps", "@jgengine/shell/structures/GeneratedBuilding#BuildingFacade", "@jgengine/shell/structures/GeneratedBuilding#BuildingKitRenderer", - "@jgengine/shell/structures/GeneratedBuilding#BuildingMaterialPalette", "@jgengine/shell/structures/GeneratedBuilding#BuildingPartKind", "@jgengine/shell/structures/GeneratedBuilding#BuildingPartPlacement", "@jgengine/shell/structures/GeneratedBuilding#GeneratedBuilding", diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 3080e936..df34df9b 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -852,6 +852,7 @@ "./structures/TransformGizmo", "./structures/buildingKitFit", "./structures/buildingKitRegistry", + "./structures/buildingSurface", "./structures/index", "./terrain", "./terrain/CarvedTerrain",