diff --git a/src/core/selection.ts b/src/core/selection.ts index 44efcd0d..3bbe69c5 100644 --- a/src/core/selection.ts +++ b/src/core/selection.ts @@ -3,3 +3,28 @@ export type EditorSelection = | { kind: "brushes"; ids: string[] } | { kind: "entities"; ids: string[] } | { kind: "modelInstances"; ids: string[] }; + +export function cloneEditorSelection(selection: EditorSelection): EditorSelection { + if (selection.kind === "none") { + return { + kind: "none" + }; + } + + return { + kind: selection.kind, + ids: [...selection.ids] + }; +} + +export function getSingleSelectedBrushId(selection: EditorSelection): string | null { + if (selection.kind !== "brushes" || selection.ids.length !== 1) { + return null; + } + + return selection.ids[0]; +} + +export function isBrushSelected(selection: EditorSelection, brushId: string): boolean { + return selection.kind === "brushes" && selection.ids.includes(brushId); +} diff --git a/src/document/brushes.ts b/src/document/brushes.ts new file mode 100644 index 00000000..2728232a --- /dev/null +++ b/src/document/brushes.ts @@ -0,0 +1,117 @@ +import { createOpaqueId } from "../core/ids"; +import type { Vec3 } from "../core/vector"; + +export const BOX_FACE_IDS = ["posX", "negX", "posY", "negY", "posZ", "negZ"] as const; + +export type BoxFaceId = (typeof BOX_FACE_IDS)[number]; + +export interface BrushFace { + materialId: string | null; +} + +export type BoxBrushFaces = Record; + +export interface BoxBrush { + id: string; + kind: "box"; + center: Vec3; + size: Vec3; + faces: BoxBrushFaces; + layerId?: string; + groupId?: string; +} + +export type Brush = BoxBrush; + +export const DEFAULT_BOX_BRUSH_CENTER: Vec3 = { + x: 0, + y: 1, + z: 0 +}; + +export const DEFAULT_BOX_BRUSH_SIZE: Vec3 = { + x: 2, + y: 2, + z: 2 +}; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function cloneBrushFace(face: BrushFace): BrushFace { + return { + materialId: face.materialId + }; +} + +export function isBoxFaceId(value: unknown): value is BoxFaceId { + return typeof value === "string" && BOX_FACE_IDS.some((faceId) => faceId === value); +} + +export function hasPositiveBoxSize(size: Vec3): boolean { + return size.x > 0 && size.y > 0 && size.z > 0; +} + +export function cloneBoxBrushFaces(faces: BoxBrushFaces): BoxBrushFaces { + return { + posX: cloneBrushFace(faces.posX), + negX: cloneBrushFace(faces.negX), + posY: cloneBrushFace(faces.posY), + negY: cloneBrushFace(faces.negY), + posZ: cloneBrushFace(faces.posZ), + negZ: cloneBrushFace(faces.negZ) + }; +} + +export function createDefaultBoxBrushFaces(): BoxBrushFaces { + return { + posX: { + materialId: null + }, + negX: { + materialId: null + }, + posY: { + materialId: null + }, + negY: { + materialId: null + }, + posZ: { + materialId: null + }, + negZ: { + materialId: null + } + }; +} + +export function createBoxBrush( + overrides: Partial> = {} +): BoxBrush { + const center = cloneVec3(overrides.center ?? DEFAULT_BOX_BRUSH_CENTER); + const size = cloneVec3(overrides.size ?? DEFAULT_BOX_BRUSH_SIZE); + + if (!hasPositiveBoxSize(size)) { + throw new Error("Box brush size must remain positive on every axis."); + } + + return { + id: overrides.id ?? createOpaqueId("brush"), + kind: "box", + center, + size, + faces: overrides.faces === undefined ? createDefaultBoxBrushFaces() : cloneBoxBrushFaces(overrides.faces), + layerId: overrides.layerId, + groupId: overrides.groupId + }; +} + +export function cloneBoxBrush(brush: BoxBrush): BoxBrush { + return createBoxBrush(brush); +} diff --git a/src/geometry/box-brush.ts b/src/geometry/box-brush.ts new file mode 100644 index 00000000..dd56415d --- /dev/null +++ b/src/geometry/box-brush.ts @@ -0,0 +1,47 @@ +import type { Vec3 } from "../core/vector"; +import type { BoxBrush } from "../document/brushes"; + +export interface BoxBrushBounds { + min: Vec3; + max: Vec3; +} + +export function getBoxBrushHalfSize(brush: BoxBrush): Vec3 { + return { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + }; +} + +export function getBoxBrushBounds(brush: BoxBrush): BoxBrushBounds { + const halfSize = getBoxBrushHalfSize(brush); + + return { + min: { + x: brush.center.x - halfSize.x, + y: brush.center.y - halfSize.y, + z: brush.center.z - halfSize.z + }, + max: { + x: brush.center.x + halfSize.x, + y: brush.center.y + halfSize.y, + z: brush.center.z + halfSize.z + } + }; +} + +export function getBoxBrushCornerPositions(brush: BoxBrush): Vec3[] { + const bounds = getBoxBrushBounds(brush); + + return [ + { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, + { x: bounds.max.x, y: bounds.min.y, z: bounds.min.z }, + { x: bounds.min.x, y: bounds.max.y, z: bounds.min.z }, + { x: bounds.max.x, y: bounds.max.y, z: bounds.min.z }, + { x: bounds.min.x, y: bounds.min.y, z: bounds.max.z }, + { x: bounds.max.x, y: bounds.min.y, z: bounds.max.z }, + { x: bounds.min.x, y: bounds.max.y, z: bounds.max.z }, + { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z } + ]; +} diff --git a/src/geometry/grid-snapping.ts b/src/geometry/grid-snapping.ts new file mode 100644 index 00000000..737098d5 --- /dev/null +++ b/src/geometry/grid-snapping.ts @@ -0,0 +1,48 @@ +import type { Vec3 } from "../core/vector"; + +export const DEFAULT_GRID_SIZE = 1; + +function assertGridSize(gridSize: number): number { + if (!Number.isFinite(gridSize) || gridSize <= 0) { + throw new Error("Grid size must be a positive finite number."); + } + + return gridSize; +} + +export function snapValueToGrid(value: number, gridSize = DEFAULT_GRID_SIZE): number { + const step = assertGridSize(gridSize); + + if (!Number.isFinite(value)) { + throw new Error("Grid-snapped values must be finite numbers."); + } + + return Math.round(value / step) * step; +} + +function snapPositiveSizeValue(value: number, gridSize: number): number { + if (!Number.isFinite(value)) { + throw new Error("Box brush size values must be finite numbers."); + } + + const snappedSize = Math.round(Math.abs(value) / gridSize) * gridSize; + return snappedSize > 0 ? snappedSize : gridSize; +} + +export function snapVec3ToGrid(vector: Vec3, gridSize = DEFAULT_GRID_SIZE): Vec3 { + return { + x: snapValueToGrid(vector.x, gridSize), + y: snapValueToGrid(vector.y, gridSize), + z: snapValueToGrid(vector.z, gridSize) + }; +} + +export function snapPositiveSizeToGrid(size: Vec3, gridSize = DEFAULT_GRID_SIZE): Vec3 { + const step = assertGridSize(gridSize); + + return { + x: snapPositiveSizeValue(size.x, step), + y: snapPositiveSizeValue(size.y, step), + z: snapPositiveSizeValue(size.z, step) + }; +}