Add commands and update brush face handling
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
|
||||||
import type { BoxBrush } from "../document/brushes";
|
import { cloneFaceUvState, type BoxBrush, type BoxFaceId, type BrushFace } from "../document/brushes";
|
||||||
import type { SceneDocument } from "../document/scene-document";
|
import type { SceneDocument } from "../document/scene-document";
|
||||||
|
|
||||||
export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): BoxBrush {
|
export function getBoxBrushOrThrow(document: SceneDocument, brushId: string): BoxBrush {
|
||||||
@@ -23,6 +23,14 @@ export function setSingleBrushSelection(brushId: string): EditorSelection {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSingleBrushFaceSelection(brushId: string, faceId: BoxFaceId): EditorSelection {
|
||||||
|
return {
|
||||||
|
kind: "brushFace",
|
||||||
|
brushId,
|
||||||
|
faceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function cloneSelectionForCommand(selection: EditorSelection): EditorSelection {
|
export function cloneSelectionForCommand(selection: EditorSelection): EditorSelection {
|
||||||
return cloneEditorSelection(selection);
|
return cloneEditorSelection(selection);
|
||||||
}
|
}
|
||||||
@@ -48,3 +56,29 @@ export function removeBrush(document: SceneDocument, brushId: string): SceneDocu
|
|||||||
brushes: remainingBrushes
|
brushes: remainingBrushes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBoxBrushFaceOrThrow(document: SceneDocument, brushId: string, faceId: BoxFaceId): BrushFace {
|
||||||
|
const brush = getBoxBrushOrThrow(document, brushId);
|
||||||
|
const face = brush.faces[faceId];
|
||||||
|
|
||||||
|
if (face === undefined) {
|
||||||
|
throw new Error(`Box brush ${brushId} does not contain face ${faceId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceBoxBrushFace(document: SceneDocument, brushId: string, faceId: BoxFaceId, face: BrushFace): SceneDocument {
|
||||||
|
const brush = getBoxBrushOrThrow(document, brushId);
|
||||||
|
|
||||||
|
return replaceBrush(document, {
|
||||||
|
...brush,
|
||||||
|
faces: {
|
||||||
|
...brush.faces,
|
||||||
|
[faceId]: {
|
||||||
|
materialId: face.materialId,
|
||||||
|
uv: cloneFaceUvState(face.uv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
81
src/commands/set-box-brush-face-material-command.ts
Normal file
81
src/commands/set-box-brush-face-material-command.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ToolMode } from "../core/tool-mode";
|
||||||
|
import { createOpaqueId } from "../core/ids";
|
||||||
|
import type { EditorSelection } from "../core/selection";
|
||||||
|
import type { BoxFaceId } from "../document/brushes";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneSelectionForCommand,
|
||||||
|
getBoxBrushFaceOrThrow,
|
||||||
|
replaceBoxBrushFace,
|
||||||
|
setSingleBrushFaceSelection
|
||||||
|
} from "./brush-command-helpers";
|
||||||
|
import type { EditorCommand } from "./command";
|
||||||
|
|
||||||
|
interface SetBoxBrushFaceMaterialCommandOptions {
|
||||||
|
brushId: string;
|
||||||
|
faceId: BoxFaceId;
|
||||||
|
materialId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSetBoxBrushFaceMaterialCommand(options: SetBoxBrushFaceMaterialCommandOptions): EditorCommand {
|
||||||
|
let previousMaterialId: string | null | undefined;
|
||||||
|
let previousSelection: EditorSelection | null = null;
|
||||||
|
let previousToolMode: ToolMode | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createOpaqueId("command"),
|
||||||
|
label: options.materialId === null ? `Clear ${options.faceId} face material` : `Apply material to ${options.faceId} face`,
|
||||||
|
execute(context) {
|
||||||
|
const currentDocument = context.getDocument();
|
||||||
|
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||||
|
|
||||||
|
if (options.materialId !== null && currentDocument.materials[options.materialId] === undefined) {
|
||||||
|
throw new Error(`Material ${options.materialId} does not exist in the document registry.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousMaterialId === undefined) {
|
||||||
|
previousMaterialId = currentFace.materialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousSelection === null) {
|
||||||
|
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousToolMode === null) {
|
||||||
|
previousToolMode = context.getToolMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setDocument(
|
||||||
|
replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||||
|
...currentFace,
|
||||||
|
materialId: options.materialId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
|
||||||
|
context.setToolMode("select");
|
||||||
|
},
|
||||||
|
undo(context) {
|
||||||
|
if (previousMaterialId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDocument = context.getDocument();
|
||||||
|
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||||
|
|
||||||
|
context.setDocument(
|
||||||
|
replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||||
|
...currentFace,
|
||||||
|
materialId: previousMaterialId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousSelection !== null) {
|
||||||
|
context.setSelection(previousSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousToolMode !== null) {
|
||||||
|
context.setToolMode(previousToolMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/commands/set-box-brush-face-uv-state-command.ts
Normal file
78
src/commands/set-box-brush-face-uv-state-command.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { ToolMode } from "../core/tool-mode";
|
||||||
|
import { createOpaqueId } from "../core/ids";
|
||||||
|
import type { EditorSelection } from "../core/selection";
|
||||||
|
import { cloneFaceUvState, type BoxFaceId, type FaceUvState } from "../document/brushes";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneSelectionForCommand,
|
||||||
|
getBoxBrushFaceOrThrow,
|
||||||
|
replaceBoxBrushFace,
|
||||||
|
setSingleBrushFaceSelection
|
||||||
|
} from "./brush-command-helpers";
|
||||||
|
import type { EditorCommand } from "./command";
|
||||||
|
|
||||||
|
interface SetBoxBrushFaceUvStateCommandOptions {
|
||||||
|
brushId: string;
|
||||||
|
faceId: BoxFaceId;
|
||||||
|
uvState: FaceUvState;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSetBoxBrushFaceUvStateCommand(options: SetBoxBrushFaceUvStateCommandOptions): EditorCommand {
|
||||||
|
let previousUvState: FaceUvState | null = null;
|
||||||
|
let previousSelection: EditorSelection | null = null;
|
||||||
|
let previousToolMode: ToolMode | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createOpaqueId("command"),
|
||||||
|
label: options.label ?? `Update ${options.faceId} face UVs`,
|
||||||
|
execute(context) {
|
||||||
|
const currentDocument = context.getDocument();
|
||||||
|
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||||
|
|
||||||
|
if (previousUvState === null) {
|
||||||
|
previousUvState = cloneFaceUvState(currentFace.uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousSelection === null) {
|
||||||
|
previousSelection = cloneSelectionForCommand(context.getSelection());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousToolMode === null) {
|
||||||
|
previousToolMode = context.getToolMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setDocument(
|
||||||
|
replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||||
|
...currentFace,
|
||||||
|
uv: cloneFaceUvState(options.uvState)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
|
||||||
|
context.setToolMode("select");
|
||||||
|
},
|
||||||
|
undo(context) {
|
||||||
|
if (previousUvState === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDocument = context.getDocument();
|
||||||
|
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
|
||||||
|
|
||||||
|
context.setDocument(
|
||||||
|
replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
|
||||||
|
...currentFace,
|
||||||
|
uv: cloneFaceUvState(previousUvState)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousSelection !== null) {
|
||||||
|
context.setSelection(previousSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousToolMode !== null) {
|
||||||
|
context.setToolMode(previousToolMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { BoxFaceId } from "../document/brushes";
|
||||||
|
|
||||||
export type EditorSelection =
|
export type EditorSelection =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
| { kind: "brushes"; ids: string[] }
|
| { kind: "brushes"; ids: string[] }
|
||||||
|
| { kind: "brushFace"; brushId: string; faceId: BoxFaceId }
|
||||||
| { kind: "entities"; ids: string[] }
|
| { kind: "entities"; ids: string[] }
|
||||||
| { kind: "modelInstances"; ids: string[] };
|
| { kind: "modelInstances"; ids: string[] };
|
||||||
|
|
||||||
@@ -11,6 +14,14 @@ export function cloneEditorSelection(selection: EditorSelection): EditorSelectio
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selection.kind === "brushFace") {
|
||||||
|
return {
|
||||||
|
kind: "brushFace",
|
||||||
|
brushId: selection.brushId,
|
||||||
|
faceId: selection.faceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: selection.kind,
|
kind: selection.kind,
|
||||||
ids: [...selection.ids]
|
ids: [...selection.ids]
|
||||||
@@ -18,6 +29,10 @@ export function cloneEditorSelection(selection: EditorSelection): EditorSelectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSingleSelectedBrushId(selection: EditorSelection): string | null {
|
export function getSingleSelectedBrushId(selection: EditorSelection): string | null {
|
||||||
|
if (selection.kind === "brushFace") {
|
||||||
|
return selection.brushId;
|
||||||
|
}
|
||||||
|
|
||||||
if (selection.kind !== "brushes" || selection.ids.length !== 1) {
|
if (selection.kind !== "brushes" || selection.ids.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -25,6 +40,21 @@ export function getSingleSelectedBrushId(selection: EditorSelection): string | n
|
|||||||
return selection.ids[0];
|
return selection.ids[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBrushSelected(selection: EditorSelection, brushId: string): boolean {
|
export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | null {
|
||||||
return selection.kind === "brushes" && selection.ids.includes(brushId);
|
if (selection.kind !== "brushFace") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection.faceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrushSelected(selection: EditorSelection, brushId: string): boolean {
|
||||||
|
return (
|
||||||
|
(selection.kind === "brushes" && selection.ids.includes(brushId)) ||
|
||||||
|
(selection.kind === "brushFace" && selection.brushId === brushId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrushFaceSelected(selection: EditorSelection, brushId: string, faceId: BoxFaceId): boolean {
|
||||||
|
return selection.kind === "brushFace" && selection.brushId === brushId && selection.faceId === faceId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface Vec2 {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Vec3 {
|
export interface Vec3 {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { createOpaqueId } from "../core/ids";
|
import { createOpaqueId } from "../core/ids";
|
||||||
import type { Vec3 } from "../core/vector";
|
import type { Vec2, Vec3 } from "../core/vector";
|
||||||
|
|
||||||
export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"] as const;
|
export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"] as const;
|
||||||
|
export const FACE_UV_ROTATION_QUARTER_TURNS = [0, 1, 2, 3] as const;
|
||||||
|
|
||||||
export type BoxFaceId = (typeof BOX_FACE_IDS)[number];
|
export type BoxFaceId = (typeof BOX_FACE_IDS)[number];
|
||||||
|
export type FaceUvRotationQuarterTurns = (typeof FACE_UV_ROTATION_QUARTER_TURNS)[number];
|
||||||
|
|
||||||
|
export interface FaceUvState {
|
||||||
|
offset: Vec2;
|
||||||
|
scale: Vec2;
|
||||||
|
rotationQuarterTurns: FaceUvRotationQuarterTurns;
|
||||||
|
flipU: boolean;
|
||||||
|
flipV: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrushFace {
|
export interface BrushFace {
|
||||||
materialId: string | null;
|
materialId: string | null;
|
||||||
|
uv: FaceUvState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoxBrushFaces = Record<BoxFaceId, BrushFace>;
|
export type BoxBrushFaces = Record<BoxFaceId, BrushFace>;
|
||||||
@@ -45,7 +56,8 @@ function cloneVec3(vector: Vec3): Vec3 {
|
|||||||
|
|
||||||
function cloneBrushFace(face: BrushFace): BrushFace {
|
function cloneBrushFace(face: BrushFace): BrushFace {
|
||||||
return {
|
return {
|
||||||
materialId: face.materialId
|
materialId: face.materialId,
|
||||||
|
uv: cloneFaceUvState(face.uv)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +65,44 @@ export function isBoxFaceId(value: unknown): value is BoxFaceId {
|
|||||||
return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value);
|
return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFaceUvRotationQuarterTurns(value: unknown): value is FaceUvRotationQuarterTurns {
|
||||||
|
return typeof value === "number" && FACE_UV_ROTATION_QUARTER_TURNS.includes(value as FaceUvRotationQuarterTurns);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasPositiveBoxSize(size: Vec3): boolean {
|
export function hasPositiveBoxSize(size: Vec3): boolean {
|
||||||
return size.x > 0 && size.y > 0 && size.z > 0;
|
return size.x > 0 && size.y > 0 && size.z > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createDefaultFaceUvState(): FaceUvState {
|
||||||
|
return {
|
||||||
|
offset: {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
x: 1,
|
||||||
|
y: 1
|
||||||
|
},
|
||||||
|
rotationQuarterTurns: 0,
|
||||||
|
flipU: false,
|
||||||
|
flipV: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFaceUvState(uv: FaceUvState): FaceUvState {
|
||||||
|
return {
|
||||||
|
offset: {
|
||||||
|
...uv.offset
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
...uv.scale
|
||||||
|
},
|
||||||
|
rotationQuarterTurns: uv.rotationQuarterTurns,
|
||||||
|
flipU: uv.flipU,
|
||||||
|
flipV: uv.flipV
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces {
|
export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces {
|
||||||
return {
|
return {
|
||||||
posX: cloneBrushFace(faces.posX),
|
posX: cloneBrushFace(faces.posX),
|
||||||
@@ -71,22 +117,28 @@ export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces {
|
|||||||
export function createDefaultBoxBrushFaces(): BoxBrushFaces {
|
export function createDefaultBoxBrushFaces(): BoxBrushFaces {
|
||||||
return {
|
return {
|
||||||
posX: {
|
posX: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
},
|
},
|
||||||
negX: {
|
negX: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
},
|
},
|
||||||
posY: {
|
posY: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
},
|
},
|
||||||
negY: {
|
negY: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
},
|
},
|
||||||
posZ: {
|
posZ: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
},
|
},
|
||||||
negZ: {
|
negZ: {
|
||||||
materialId: null
|
materialId: null,
|
||||||
|
uv: createDefaultFaceUvState()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector";
|
import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector";
|
||||||
import type { Brush } from "./brushes";
|
import type { Brush } from "./brushes";
|
||||||
|
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
|
||||||
|
|
||||||
export const SCENE_DOCUMENT_VERSION = 2 as const;
|
export const SCENE_DOCUMENT_VERSION = 3 as const;
|
||||||
export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const;
|
export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const;
|
||||||
|
export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2 as const;
|
||||||
|
|
||||||
export interface WorldBackgroundSettings {
|
export interface WorldBackgroundSettings {
|
||||||
mode: "solid";
|
mode: "solid";
|
||||||
@@ -30,7 +32,7 @@ export interface SceneDocument {
|
|||||||
version: typeof SCENE_DOCUMENT_VERSION;
|
version: typeof SCENE_DOCUMENT_VERSION;
|
||||||
name: string;
|
name: string;
|
||||||
world: WorldSettings;
|
world: WorldSettings;
|
||||||
materials: Record<string, never>;
|
materials: Record<string, MaterialDef>;
|
||||||
textures: Record<string, never>;
|
textures: Record<string, never>;
|
||||||
assets: Record<string, never>;
|
assets: Record<string, never>;
|
||||||
brushes: Record<string, Brush>;
|
brushes: Record<string, Brush>;
|
||||||
@@ -59,12 +61,12 @@ export function createDefaultWorldSettings(): WorldSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world">> = {}): SceneDocument {
|
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}): SceneDocument {
|
||||||
return {
|
return {
|
||||||
version: SCENE_DOCUMENT_VERSION,
|
version: SCENE_DOCUMENT_VERSION,
|
||||||
name: overrides.name ?? "Untitled Scene",
|
name: overrides.name ?? "Untitled Scene",
|
||||||
world: overrides.world ?? createDefaultWorldSettings(),
|
world: overrides.world ?? createDefaultWorldSettings(),
|
||||||
materials: {},
|
materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()),
|
||||||
textures: {},
|
textures: {},
|
||||||
assets: {},
|
assets: {},
|
||||||
brushes: {},
|
brushes: {},
|
||||||
|
|||||||
154
src/geometry/box-face-uvs.ts
Normal file
154
src/geometry/box-face-uvs.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { BoxGeometry } from "three";
|
||||||
|
|
||||||
|
import type { Vec2, Vec3 } from "../core/vector";
|
||||||
|
import { BOX_FACE_IDS, createDefaultFaceUvState, type BoxBrush, type BoxFaceId, type FaceUvState } from "../document/brushes";
|
||||||
|
import { getBoxBrushHalfSize } from "./box-brush";
|
||||||
|
|
||||||
|
export function getBoxBrushFaceSize(brush: BoxBrush, faceId: BoxFaceId): Vec2 {
|
||||||
|
switch (faceId) {
|
||||||
|
case "posX":
|
||||||
|
case "negX":
|
||||||
|
return {
|
||||||
|
x: brush.size.z,
|
||||||
|
y: brush.size.y
|
||||||
|
};
|
||||||
|
case "posY":
|
||||||
|
case "negY":
|
||||||
|
return {
|
||||||
|
x: brush.size.x,
|
||||||
|
y: brush.size.z
|
||||||
|
};
|
||||||
|
case "posZ":
|
||||||
|
case "negZ":
|
||||||
|
return {
|
||||||
|
x: brush.size.x,
|
||||||
|
y: brush.size.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFitToFaceBoxBrushFaceUvState(brush: BoxBrush, faceId: BoxFaceId): FaceUvState {
|
||||||
|
const faceSize = getBoxBrushFaceSize(brush, faceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createDefaultFaceUvState(),
|
||||||
|
scale: {
|
||||||
|
x: 1 / faceSize.x,
|
||||||
|
y: 1 / faceSize.y
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectBoxFaceVertexToUv(vertexPosition: Vec3, brush: BoxBrush, faceId: BoxFaceId): Vec2 {
|
||||||
|
const halfSize = getBoxBrushHalfSize(brush);
|
||||||
|
|
||||||
|
switch (faceId) {
|
||||||
|
case "posX":
|
||||||
|
return {
|
||||||
|
x: halfSize.z - vertexPosition.z,
|
||||||
|
y: vertexPosition.y + halfSize.y
|
||||||
|
};
|
||||||
|
case "negX":
|
||||||
|
return {
|
||||||
|
x: vertexPosition.z + halfSize.z,
|
||||||
|
y: vertexPosition.y + halfSize.y
|
||||||
|
};
|
||||||
|
case "posY":
|
||||||
|
return {
|
||||||
|
x: vertexPosition.x + halfSize.x,
|
||||||
|
y: halfSize.z - vertexPosition.z
|
||||||
|
};
|
||||||
|
case "negY":
|
||||||
|
return {
|
||||||
|
x: vertexPosition.x + halfSize.x,
|
||||||
|
y: vertexPosition.z + halfSize.z
|
||||||
|
};
|
||||||
|
case "posZ":
|
||||||
|
return {
|
||||||
|
x: vertexPosition.x + halfSize.x,
|
||||||
|
y: vertexPosition.y + halfSize.y
|
||||||
|
};
|
||||||
|
case "negZ":
|
||||||
|
return {
|
||||||
|
x: halfSize.x - vertexPosition.x,
|
||||||
|
y: vertexPosition.y + halfSize.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformProjectedFaceUv(baseUv: Vec2, faceSize: Vec2, uvState: FaceUvState): Vec2 {
|
||||||
|
let u = (baseUv.x - faceSize.x * 0.5) * uvState.scale.x;
|
||||||
|
let v = (baseUv.y - faceSize.y * 0.5) * uvState.scale.y;
|
||||||
|
|
||||||
|
if (uvState.flipU) {
|
||||||
|
u *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uvState.flipV) {
|
||||||
|
v *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (uvState.rotationQuarterTurns) {
|
||||||
|
case 1: {
|
||||||
|
const nextU = -v;
|
||||||
|
v = u;
|
||||||
|
u = nextU;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
u *= -1;
|
||||||
|
v *= -1;
|
||||||
|
break;
|
||||||
|
case 3: {
|
||||||
|
const nextU = v;
|
||||||
|
v = -u;
|
||||||
|
u = nextU;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: u + faceSize.x * 0.5 * uvState.scale.x + uvState.offset.x,
|
||||||
|
y: v + faceSize.y * 0.5 * uvState.scale.y + uvState.offset.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBoxBrushFaceUvsToGeometry(geometry: BoxGeometry, brush: BoxBrush): void {
|
||||||
|
const positionAttribute = geometry.getAttribute("position");
|
||||||
|
const uvAttribute = geometry.getAttribute("uv");
|
||||||
|
const indexAttribute = geometry.getIndex();
|
||||||
|
|
||||||
|
if (indexAttribute === null) {
|
||||||
|
throw new Error("BoxGeometry is expected to be indexed for face UV projection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxGeometry groups follow the same px, nx, py, ny, pz, nz order as the canonical face ids.
|
||||||
|
for (const [materialIndex, faceId] of BOX_FACE_IDS.entries()) {
|
||||||
|
const group = geometry.groups.find((candidate) => candidate.materialIndex === materialIndex);
|
||||||
|
|
||||||
|
if (group === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceSize = getBoxBrushFaceSize(brush, faceId);
|
||||||
|
const vertexIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (let indexOffset = group.start; indexOffset < group.start + group.count; indexOffset += 1) {
|
||||||
|
vertexIndices.add(indexAttribute.getX(indexOffset));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vertexIndex of vertexIndices) {
|
||||||
|
const localVertexPosition = {
|
||||||
|
x: positionAttribute.getX(vertexIndex),
|
||||||
|
y: positionAttribute.getY(vertexIndex),
|
||||||
|
z: positionAttribute.getZ(vertexIndex)
|
||||||
|
};
|
||||||
|
const projectedUv = projectBoxFaceVertexToUv(localVertexPosition, brush, faceId);
|
||||||
|
const transformedUv = transformProjectedFaceUv(projectedUv, faceSize, brush.faces[faceId].uv);
|
||||||
|
|
||||||
|
uvAttribute.setXY(vertexIndex, transformedUv.x, transformedUv.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uvAttribute.needsUpdate = true;
|
||||||
|
}
|
||||||
62
src/materials/starter-material-library.ts
Normal file
62
src/materials/starter-material-library.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type MaterialPattern = "grid" | "checker" | "stripes" | "diamond";
|
||||||
|
|
||||||
|
export interface MaterialDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
baseColorHex: string;
|
||||||
|
accentColorHex: string;
|
||||||
|
pattern: MaterialPattern;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STARTER_MATERIAL_LIBRARY: readonly MaterialDef[] = [
|
||||||
|
{
|
||||||
|
id: "starter-amber-grid",
|
||||||
|
name: "Amber Grid",
|
||||||
|
baseColorHex: "#c79a63",
|
||||||
|
accentColorHex: "#5f3820",
|
||||||
|
pattern: "grid",
|
||||||
|
tags: ["starter", "wall"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter-concrete-checker",
|
||||||
|
name: "Concrete Checker",
|
||||||
|
baseColorHex: "#7d838c",
|
||||||
|
accentColorHex: "#5a616a",
|
||||||
|
pattern: "checker",
|
||||||
|
tags: ["starter", "floor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter-hazard-stripe",
|
||||||
|
name: "Hazard Stripe",
|
||||||
|
baseColorHex: "#d1a245",
|
||||||
|
accentColorHex: "#211b16",
|
||||||
|
pattern: "stripes",
|
||||||
|
tags: ["starter", "warning"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter-night-diamond",
|
||||||
|
name: "Night Diamond",
|
||||||
|
baseColorHex: "#5a6985",
|
||||||
|
accentColorHex: "#1f2836",
|
||||||
|
pattern: "diamond",
|
||||||
|
tags: ["starter", "trim"]
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function cloneMaterialDef(material: MaterialDef): MaterialDef {
|
||||||
|
return {
|
||||||
|
...material,
|
||||||
|
tags: [...material.tags]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneMaterialRegistry(materials: Record<string, MaterialDef>): Record<string, MaterialDef> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(materials).map(([materialId, material]) => [materialId, cloneMaterialDef(material)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStarterMaterialRegistry(): Record<string, MaterialDef> {
|
||||||
|
return Object.fromEntries(STARTER_MATERIAL_LIBRARY.map((material) => [material.id, cloneMaterialDef(material)]));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user