diff --git a/src/assets/model-instances.ts b/src/assets/model-instances.ts index 0d60dd08..89478759 100644 --- a/src/assets/model-instances.ts +++ b/src/assets/model-instances.ts @@ -16,6 +16,8 @@ export interface ModelInstance { kind: "modelInstance"; assetId: string; name?: string; + visible: boolean; + enabled: boolean; position: Vec3; rotationDegrees: Vec3; scale: Vec3; @@ -42,6 +44,9 @@ export const DEFAULT_MODEL_INSTANCE_SCALE: Vec3 = { z: 1 }; +export const DEFAULT_MODEL_INSTANCE_VISIBLE = true; +export const DEFAULT_MODEL_INSTANCE_ENABLED = true; + export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS: ModelInstanceCollisionSettings = { mode: "none", visible: false @@ -117,7 +122,7 @@ function assertPositiveFiniteVec3(vector: Vec3, label: string) { export function createModelInstance( overrides: Partial< - Pick + Pick > & Pick ): ModelInstance { @@ -125,6 +130,8 @@ export function createModelInstance( const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES); const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE); const collision = cloneModelInstanceCollisionSettings(overrides.collision ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS); + const visible = overrides.visible ?? DEFAULT_MODEL_INSTANCE_VISIBLE; + const enabled = overrides.enabled ?? DEFAULT_MODEL_INSTANCE_ENABLED; if (overrides.assetId.trim().length === 0) { throw new Error("Model instance assetId must be a non-empty string."); @@ -134,11 +141,21 @@ export function createModelInstance( assertFiniteVec3(rotationDegrees, "Model instance rotation"); assertPositiveFiniteVec3(scale, "Model instance scale"); + if (typeof visible !== "boolean") { + throw new Error("Model instance visible must be a boolean."); + } + + if (typeof enabled !== "boolean") { + throw new Error("Model instance enabled must be a boolean."); + } + return { id: overrides.id ?? createOpaqueId("model-instance"), kind: "modelInstance", assetId: overrides.assetId, name: normalizeModelInstanceName(overrides.name), + visible, + enabled, position, rotationDegrees, scale, @@ -178,6 +195,8 @@ export function areModelInstancesEqual(left: ModelInstance, right: ModelInstance left.kind === right.kind && left.assetId === right.assetId && left.name === right.name && + left.visible === right.visible && + left.enabled === right.enabled && areVec3Equal(left.position, right.position) && areVec3Equal(left.rotationDegrees, right.rotationDegrees) && areVec3Equal(left.scale, right.scale) && diff --git a/src/document/brushes.ts b/src/document/brushes.ts index 8b5f925a..adba66e3 100644 --- a/src/document/brushes.ts +++ b/src/document/brushes.ts @@ -122,6 +122,8 @@ export interface BoxBrush { id: string; kind: "box"; name?: string; + visible: boolean; + enabled: boolean; center: Vec3; rotationDegrees: Vec3; size: Vec3; @@ -152,6 +154,9 @@ export const DEFAULT_BOX_BRUSH_ROTATION_DEGREES: Vec3 = { z: 0 }; +export const DEFAULT_BOX_BRUSH_VISIBLE = true; +export const DEFAULT_BOX_BRUSH_ENABLED = true; + export const DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 6; export const MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT = 24; @@ -482,7 +487,7 @@ export function cloneBoxBrushVolumeSettings(volume: BoxBrushVolumeSettings): Box export function createBoxBrush( overrides: Partial< - Pick + Pick > = {} ): BoxBrush { const center = cloneVec3(overrides.center ?? DEFAULT_BOX_BRUSH_CENTER); @@ -490,15 +495,27 @@ export function createBoxBrush( const fallbackSize = cloneVec3(overrides.size ?? DEFAULT_BOX_BRUSH_SIZE); const geometry = overrides.geometry === undefined ? createDefaultBoxBrushGeometry(fallbackSize) : cloneBoxBrushGeometry(overrides.geometry); const size = deriveBoxBrushSizeFromGeometry(geometry); + const visible = overrides.visible ?? DEFAULT_BOX_BRUSH_VISIBLE; + const enabled = overrides.enabled ?? DEFAULT_BOX_BRUSH_ENABLED; if (!hasPositiveBoxSize(size)) { throw new Error("Box brush size must remain positive on every axis."); } + if (typeof visible !== "boolean") { + throw new Error("Box brush visible must be a boolean."); + } + + if (typeof enabled !== "boolean") { + throw new Error("Box brush enabled must be a boolean."); + } + return { id: overrides.id ?? createOpaqueId("brush"), kind: "box", name: normalizeBrushName(overrides.name), + visible, + enabled, center, rotationDegrees, size, diff --git a/src/entities/entity-instances.ts b/src/entities/entity-instances.ts index 4480ff0f..8f2116d6 100644 --- a/src/entities/entity-instances.ts +++ b/src/entities/entity-instances.ts @@ -5,6 +5,8 @@ import { isHexColorString } from "../document/world-settings"; interface PositionedEntity { id: string; name?: string; + visible: boolean; + enabled: boolean; position: Vec3; } @@ -218,14 +220,14 @@ export interface InteractableEntity extends PositionedEntity { kind: "interactable"; radius: number; prompt: string; - enabled: boolean; + interactionEnabled: boolean; } export interface SceneExitEntity extends PositionedEntity { kind: "sceneExit"; radius: number; prompt: string; - enabled: boolean; + interactionEnabled: boolean; targetSceneId: string; targetEntryEntityId: string; } @@ -290,6 +292,9 @@ export const DEFAULT_ENTITY_POSITION: Vec3 = { z: 0 }; +export const DEFAULT_ENTITY_VISIBLE = true; +export const DEFAULT_ENTITY_ENABLED = true; + export const DEFAULT_PLAYER_START_POSITION = DEFAULT_ENTITY_POSITION; export const DEFAULT_PLAYER_START_YAW_DEGREES = 0; export const DEFAULT_PLAYER_START_NAVIGATION_MODE: PlayerStartNavigationMode = @@ -1062,6 +1067,20 @@ export function normalizeEntityName(name: string | null | undefined): string | u return trimmedName.length === 0 ? undefined : trimmedName; } +function resolveAuthoredEntityVisibility(visible: boolean | undefined): boolean { + const resolvedVisible = visible ?? DEFAULT_ENTITY_VISIBLE; + + assertBoolean(resolvedVisible, "Entity visible"); + return resolvedVisible; +} + +function resolveAuthoredEntityEnabled(enabled: boolean | undefined): boolean { + const resolvedEnabled = enabled ?? DEFAULT_ENTITY_ENABLED; + + assertBoolean(resolvedEnabled, "Entity enabled"); + return resolvedEnabled; +} + export function normalizeYawDegrees(yawDegrees: number): number { const normalizedYaw = yawDegrees % 360; return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw; @@ -1078,7 +1097,7 @@ export function normalizeInteractablePrompt(prompt: string): string { } export function createPointLightEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): PointLightEntity { const position = cloneVec3(overrides.position ?? DEFAULT_POINT_LIGHT_POSITION); const colorHex = overrides.colorHex ?? DEFAULT_POINT_LIGHT_COLOR_HEX; @@ -1094,6 +1113,8 @@ export function createPointLightEntity( id: overrides.id ?? createOpaqueId("entity-point-light"), kind: "pointLight", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, colorHex, intensity, @@ -1102,7 +1123,7 @@ export function createPointLightEntity( } export function createSpotLightEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): SpotLightEntity { const position = cloneVec3(overrides.position ?? DEFAULT_SPOT_LIGHT_POSITION); const direction = cloneVec3(overrides.direction ?? DEFAULT_SPOT_LIGHT_DIRECTION); @@ -1126,6 +1147,8 @@ export function createSpotLightEntity( id: overrides.id ?? createOpaqueId("entity-spot-light"), kind: "spotLight", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, direction, colorHex, @@ -1139,7 +1162,7 @@ export function createPlayerStartEntity( overrides: Partial< Pick< PlayerStartEntity, - "id" | "name" | "position" | "yawDegrees" | "navigationMode" + "id" | "name" | "visible" | "enabled" | "position" | "yawDegrees" | "navigationMode" > > & { movementTemplate?: PlayerStartMovementTemplateOverrides; @@ -1173,6 +1196,8 @@ export function createPlayerStartEntity( id: overrides.id ?? createOpaqueId("entity-player-start"), kind: "playerStart", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees), navigationMode, @@ -1183,7 +1208,7 @@ export function createPlayerStartEntity( } export function createSceneEntryEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): SceneEntryEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const yawDegrees = overrides.yawDegrees ?? DEFAULT_SCENE_ENTRY_YAW_DEGREES; @@ -1198,6 +1223,8 @@ export function createSceneEntryEntity( id: overrides.id ?? createOpaqueId("entity-scene-entry"), kind: "sceneEntry", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees) }; @@ -1207,7 +1234,7 @@ export function createSoundEmitterEntity( overrides: Partial< Pick< SoundEmitterEntity, - "id" | "name" | "position" | "audioAssetId" | "volume" | "refDistance" | "maxDistance" | "autoplay" | "loop" + "id" | "name" | "visible" | "enabled" | "position" | "audioAssetId" | "volume" | "refDistance" | "maxDistance" | "autoplay" | "loop" > > = {} ): SoundEmitterEntity { @@ -1235,6 +1262,8 @@ export function createSoundEmitterEntity( id: overrides.id ?? createOpaqueId("entity-sound-emitter"), kind: "soundEmitter", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, audioAssetId, volume, @@ -1246,7 +1275,7 @@ export function createSoundEmitterEntity( } export function createTriggerVolumeEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): TriggerVolumeEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE); @@ -1262,6 +1291,8 @@ export function createTriggerVolumeEntity( id: overrides.id ?? createOpaqueId("entity-trigger-volume"), kind: "triggerVolume", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, size, triggerOnEnter, @@ -1270,7 +1301,7 @@ export function createTriggerVolumeEntity( } export function createTeleportTargetEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): TeleportTargetEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const yawDegrees = overrides.yawDegrees ?? DEFAULT_TELEPORT_TARGET_YAW_DEGREES; @@ -1285,31 +1316,35 @@ export function createTeleportTargetEntity( id: overrides.id ?? createOpaqueId("entity-teleport-target"), kind: "teleportTarget", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees) }; } export function createInteractableEntity( - overrides: Partial> = {} + overrides: Partial> = {} ): InteractableEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const radius = overrides.radius ?? DEFAULT_INTERACTABLE_RADIUS; const prompt = normalizeInteractablePrompt(overrides.prompt ?? DEFAULT_INTERACTABLE_PROMPT); - const enabled = overrides.enabled ?? true; + const interactionEnabled = overrides.interactionEnabled ?? true; assertFiniteVec3(position, "Interactable position"); assertPositiveFiniteNumber(radius, "Interactable radius"); - assertBoolean(enabled, "Interactable enabled"); + assertBoolean(interactionEnabled, "Interactable interactionEnabled"); return { id: overrides.id ?? createOpaqueId("entity-interactable"), kind: "interactable", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, radius, prompt, - enabled + interactionEnabled }; } @@ -1319,10 +1354,12 @@ export function createSceneExitEntity( SceneExitEntity, | "id" | "name" + | "visible" + | "enabled" | "position" | "radius" | "prompt" - | "enabled" + | "interactionEnabled" | "targetSceneId" | "targetEntryEntityId" > @@ -1333,7 +1370,7 @@ export function createSceneExitEntity( const prompt = normalizeInteractablePrompt( overrides.prompt ?? DEFAULT_SCENE_EXIT_PROMPT ); - const enabled = overrides.enabled ?? true; + const interactionEnabled = overrides.interactionEnabled ?? true; const targetSceneId = normalizeSceneReferenceId( overrides.targetSceneId, "Scene Exit target scene id" @@ -1345,16 +1382,18 @@ export function createSceneExitEntity( assertFiniteVec3(position, "Scene Exit position"); assertPositiveFiniteNumber(radius, "Scene Exit radius"); - assertBoolean(enabled, "Scene Exit enabled"); + assertBoolean(interactionEnabled, "Scene Exit interactionEnabled"); return { id: overrides.id ?? createOpaqueId("entity-scene-exit"), kind: "sceneExit", name: normalizeEntityName(overrides.name), + visible: resolveAuthoredEntityVisibility(overrides.visible), + enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, radius, prompt, - enabled, + interactionEnabled, targetSceneId, targetEntryEntityId }; @@ -1488,7 +1527,14 @@ export function cloneEntityRegistry(entities: Record): R } export function areEntityInstancesEqual(left: EntityInstance, right: EntityInstance): boolean { - if (left.kind !== right.kind || left.id !== right.id || left.name !== right.name || !areVec3Equal(left.position, right.position)) { + if ( + left.kind !== right.kind || + left.id !== right.id || + left.name !== right.name || + left.visible !== right.visible || + left.enabled !== right.enabled || + !areVec3Equal(left.position, right.position) + ) { return false; } @@ -1560,14 +1606,18 @@ export function areEntityInstancesEqual(left: EntityInstance, right: EntityInsta } case "interactable": { const typedRight = right as InteractableEntity; - return left.radius === typedRight.radius && left.prompt === typedRight.prompt && left.enabled === typedRight.enabled; + return ( + left.radius === typedRight.radius && + left.prompt === typedRight.prompt && + left.interactionEnabled === typedRight.interactionEnabled + ); } case "sceneExit": { const typedRight = right as SceneExitEntity; return ( left.radius === typedRight.radius && left.prompt === typedRight.prompt && - left.enabled === typedRight.enabled && + left.interactionEnabled === typedRight.interactionEnabled && left.targetSceneId === typedRight.targetSceneId && left.targetEntryEntityId === typedRight.targetEntryEntityId ); @@ -1605,6 +1655,10 @@ export function getPrimaryPlayerStartEntity(entities: Record): PlayerStartEntity | null { + return getPlayerStartEntities(entities).find((entity) => entity.enabled) ?? null; +} + export function getEntityKindLabel(kind: EntityKind): string { return getEntityRegistryEntry(kind).label; }