diff --git a/.changeset/fruity-phones-pump.md b/.changeset/fruity-phones-pump.md new file mode 100644 index 0000000..4b38513 --- /dev/null +++ b/.changeset/fruity-phones-pump.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/voxel.renderer": minor +--- + +feat(voxel-renderer): Allow to copy (clone) an existing layer diff --git a/packages/voxel-renderer/src/VoxelRenderer.ts b/packages/voxel-renderer/src/VoxelRenderer.ts index bba6867..d3cb67c 100644 --- a/packages/voxel-renderer/src/VoxelRenderer.ts +++ b/packages/voxel-renderer/src/VoxelRenderer.ts @@ -41,7 +41,8 @@ import type { TilesetLoader } from "./tileset/TilesetLoader.ts"; import { VoxelWorld } from "./world/VoxelWorld.ts"; import { VoxelLayer, - type VoxelLayerConfigurableOptions + type VoxelLayerConfigurableOptions, + type VoxelLayerOptions } from "./world/VoxelLayer.ts"; import { VoxelChunk } from "./world/VoxelChunk.ts"; import type { VoxelEntry, VoxelCoord } from "./world/types.ts"; @@ -51,7 +52,7 @@ import type { VoxelLayerHookListener, VoxelLayerHookEvent } from "./hooks.ts"; -import type { VoxelSetOptions, VoxelRemoveOptions } from "./types.ts"; +import type { VoxelSetOptions, VoxelRemoveOptions, PartialExcept } from "./types.ts"; export type { VoxelSetOptions, VoxelRemoveOptions }; @@ -561,6 +562,21 @@ export class VoxelRenderer extends ActorComponent { return this.world.getLayer(name); } + cloneLayer(name: string, options: PartialExcept): VoxelLayer | undefined { + const clone = this.world.cloneLayer(name, options); + if (!clone) { + return undefined; + } + + this.#emitHook({ + action: "cloned", + layerName: name, + metadata: { options } + }); + + return clone; + } + addLayer( name: string, options: VoxelLayerConfigurableOptions = {} diff --git a/packages/voxel-renderer/src/hooks.ts b/packages/voxel-renderer/src/hooks.ts index 72405e7..908829b 100644 --- a/packages/voxel-renderer/src/hooks.ts +++ b/packages/voxel-renderer/src/hooks.ts @@ -2,13 +2,13 @@ import type { Vector3Like } from "three"; // Import Internal Dependencies -import type { VoxelLayerConfigurableOptions } from "./world/VoxelLayer.ts"; +import type { VoxelLayerConfigurableOptions, VoxelLayerOptions } from "./world/VoxelLayer.ts"; import type { VoxelCoord } from "./world/types.ts"; import type { VoxelObjectLayerJSON, VoxelObjectJSON } from "./serialization/VoxelSerializer.ts"; -import type { VoxelSetOptions, VoxelRemoveOptions } from "./types.ts"; +import type { VoxelSetOptions, VoxelRemoveOptions, PartialExcept } from "./types.ts"; export type VoxelLayerHookEvent = | { @@ -30,6 +30,13 @@ export type VoxelLayerHookEvent = options: Partial; }; } + | { + action: "cloned"; + layerName: string; + metadata: { + options: PartialExcept; + }; + } | { action: "offset-updated"; layerName: string; diff --git a/packages/voxel-renderer/src/types.ts b/packages/voxel-renderer/src/types.ts index 72dfae8..93c3c67 100644 --- a/packages/voxel-renderer/src/types.ts +++ b/packages/voxel-renderer/src/types.ts @@ -17,3 +17,5 @@ export interface VoxelSetOptions { export interface VoxelRemoveOptions { position: Vector3Like; } + +export type PartialExcept = Partial> & Pick; diff --git a/packages/voxel-renderer/src/world/VoxelLayer.ts b/packages/voxel-renderer/src/world/VoxelLayer.ts index 9a72a4b..fd71f17 100644 --- a/packages/voxel-renderer/src/world/VoxelLayer.ts +++ b/packages/voxel-renderer/src/world/VoxelLayer.ts @@ -368,4 +368,12 @@ export class VoxelLayer { voxels: this.#exportVoxels() }; } + + clone(opts: Partial = {}): VoxelLayer { + return new VoxelLayer({ + chunkSize: this.#chunkSize, + ...this.toJSON(), + ...opts + }); + } } diff --git a/packages/voxel-renderer/src/world/VoxelWorld.ts b/packages/voxel-renderer/src/world/VoxelWorld.ts index c40a083..2d6eb7f 100644 --- a/packages/voxel-renderer/src/world/VoxelWorld.ts +++ b/packages/voxel-renderer/src/world/VoxelWorld.ts @@ -4,7 +4,8 @@ import type { Vector3Like } from "three"; // Import Internal Dependencies import { VoxelLayer, - type VoxelLayerConfigurableOptions + type VoxelLayerConfigurableOptions, + type VoxelLayerOptions } from "./VoxelLayer.ts"; import { VoxelChunk, DEFAULT_CHUNK_SIZE } from "./VoxelChunk.ts"; import type { VoxelEntry, VoxelCoord } from "./types.ts"; @@ -14,6 +15,7 @@ import type { VoxelObjectJSON, VoxelObjectLayerJSON } from "../serialization/VoxelSerializer.ts"; +import type { PartialExcept } from "../types.ts"; // CONSTANTS let kLayerIdCounter = 0; @@ -185,6 +187,18 @@ export class VoxelWorld { ); } + cloneLayer(name: string, options: PartialExcept): VoxelLayer | undefined { + const layer = this.getLayer(name); + if (!layer) { + return undefined; + } + + const clone = layer.clone({ ...options, id: `${layer.id}_${kLayerIdCounter++}` }); + this.#layers.push(clone); + + return clone; + } + // --- Object layer management --- // addObjectLayer( diff --git a/packages/voxel-renderer/test/world/VoxelLayer.spec.ts b/packages/voxel-renderer/test/world/VoxelLayer.spec.ts index c6f1263..080182e 100644 --- a/packages/voxel-renderer/test/world/VoxelLayer.spec.ts +++ b/packages/voxel-renderer/test/world/VoxelLayer.spec.ts @@ -199,3 +199,20 @@ describe("getChunks", () => { assert.equal(chunks.length, 2); }); }); + +describe("clone", () => { + it("should clone a layer", () => { + const layer = makeLayer({ chunkSize: 4 }); + const clone = layer.clone(); + assert.deepEqual(clone.toJSON(), layer.toJSON()); + assert.notEqual(clone, layer); + }); + + it("should be able to overide or add value on the fly", () => { + const layer = makeLayer({ chunkSize: 4 }); + const clone = layer.clone({ visible: false, name: "Cloned" }); + assert.deepEqual(clone.toJSON(), { + ...layer.toJSON(), visible: false, name: "Cloned" + }); + }); +}); diff --git a/packages/voxel-renderer/test/world/VoxelWorld.spec.ts b/packages/voxel-renderer/test/world/VoxelWorld.spec.ts index ddfab35..f333e51 100644 --- a/packages/voxel-renderer/test/world/VoxelWorld.spec.ts +++ b/packages/voxel-renderer/test/world/VoxelWorld.spec.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; // Import Internal Dependencies import { VoxelWorld } from "../../src/world/VoxelWorld.ts"; +import { VoxelLayer } from "../../src/world/VoxelLayer.ts"; import { FACE } from "../../src/utils/math.ts"; function makeEntry(blockId = 1, transform = 0) { @@ -298,3 +299,36 @@ describe("VoxelWorld chunkSize", () => { assert.equal(world.chunkSize, 8); }); }); + +describe("VoxelWorld clone", () => { + it("shouldn't clone a layer that doesn't exist", () => { + const world = new VoxelWorld(8); + assert.equal(world.cloneLayer("A", { name: "A_1" }), undefined); + assert.equal(world.getLayer("A"), undefined); + }); + it("should clone a layer", () => { + const world = new VoxelWorld(8); + world.addLayer("A"); + const layer = removeId(world.getLayer("A")!); + const expected = { ...layer, name: "A_1" }; + const clone = removeId(world.cloneLayer("A", { name: "A_1" })!); + assert.deepEqual(clone, expected); + assert.deepEqual(removeId(world.getLayer("A_1")!), expected); + }); + + it("should be able to override or add properties", () => { + const world = new VoxelWorld(8); + world.addLayer("A"); + const layer = removeId(world.getLayer("A")!); + const expected = { ...layer, name: "A_1", visible: false }; + const clone = removeId(world.cloneLayer("A", { name: "A_1", visible: false })!); + assert.deepEqual(clone, expected); + assert.deepEqual(removeId(world.getLayer("A_1")!), expected); + }); + + function removeId(layer: VoxelLayer) { + const { id, ...rest } = layer.toJSON(); + + return rest; + } +});