diff --git a/README.md b/README.md index 5158a6c8..16593535 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Conventions throughout: single results are one JSON value, collections are JSON | `packages/jsx` | `@diffusionstudio/jsx` | The authoring API: element vocabulary, types, and generated assets (`generate.*`) | | `packages/assets` | `@diffusionstudio/assets` | A project's asset library: the `assets.yml` manifest, content hashing, probing, resolution | | `packages/encoder` | `@diffusionstudio/encoder` | Offline video/audio/image encoding over runtime worlds (mediabunny) | -| `packages/koota-solid` | `@diffusionstudio/koota-solid` | Solid bindings for koota, ported from `@koota/react` | +| `packages/koota` | `@diffusionstudio/koota` | Vendored fork of [pmndrs/koota](https://github.com/pmndrs/koota) (core ECS only), plus Solid bindings ported from `@koota/react` at `@diffusionstudio/koota/solid` | ## Contributing / local setup diff --git a/apps/web/package.json b/apps/web/package.json index 0c029632..4fee8f98 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,6 @@ "@diffusionstudio/cli": "*", "@diffusionstudio/encoder": "*", "@diffusionstudio/jsx": "*", - "@diffusionstudio/koota-solid": "*", "@diffusionstudio/reconciler": "*", "@diffusionstudio/runtime": "*", "@kobalte/core": "^0.13.11", @@ -35,7 +34,7 @@ "colord": "^2.9.3", "cva": "^1.0.0-beta.4", "idb": "^8.0.3", - "koota": "^0.6.6", + "@diffusionstudio/koota": "*", "mediabunny": "^1.32.2", "solid-js": "^1.9.10", "somoto": "^0.0.2", diff --git a/apps/web/src/components/canvas/canvas.tsx b/apps/web/src/components/canvas/canvas.tsx index bdb2754b..35904b53 100644 --- a/apps/web/src/components/canvas/canvas.tsx +++ b/apps/web/src/components/canvas/canvas.tsx @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { findSceneAt, screenToWorld, worldToLocal, Library, Root } from "@diffusionstudio/runtime"; import { CameraController, EngineCanvas } from "@/engine"; import { insertAsset } from "@/engine/insert-asset"; diff --git a/apps/web/src/components/canvas/draw-overlay.tsx b/apps/web/src/components/canvas/draw-overlay.tsx index b0ef9274..b176d27a 100644 --- a/apps/web/src/components/canvas/draw-overlay.tsx +++ b/apps/web/src/components/canvas/draw-overlay.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Show } from "solid-js"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Rect, Scene, SolidPaint, Text } from "@diffusionstudio/reconciler"; import { Computed, @@ -19,7 +19,7 @@ import { } from "@diffusionstudio/runtime"; import { useEditor, useTool } from "@/engine"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { Point } from "@diffusionstudio/runtime"; type ToolConfig = { diff --git a/apps/web/src/components/canvas/scene-init-overlay.tsx b/apps/web/src/components/canvas/scene-init-overlay.tsx index d35e263f..2efec5bf 100644 --- a/apps/web/src/components/canvas/scene-init-overlay.tsx +++ b/apps/web/src/components/canvas/scene-init-overlay.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createMemo, createSignal, For, Show } from "solid-js"; -import { useQuery, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useQuery, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { ChildOf, Library, Root, setCamera, Source } from "@diffusionstudio/runtime"; import { Icon } from "@/components/ui/icon"; import { diff --git a/apps/web/src/components/canvas/toolbar.tsx b/apps/web/src/components/canvas/toolbar.tsx index bd4be2aa..e03b5b5d 100644 --- a/apps/web/src/components/canvas/toolbar.tsx +++ b/apps/web/src/components/canvas/toolbar.tsx @@ -18,7 +18,7 @@ import { PromptInput } from "../genai/prompt-input"; import { ActionBar } from "../genai/action-bar"; import { Show } from "solid-js"; import { Tool, ToolType } from "@diffusionstudio/runtime"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useTool } from "@/engine"; import { usePromptInput } from "@/context/prompt-input"; diff --git a/apps/web/src/components/genai/insert.tsx b/apps/web/src/components/genai/insert.tsx index 1d7aa7b4..5c048bdc 100644 --- a/apps/web/src/components/genai/insert.tsx +++ b/apps/web/src/components/genai/insert.tsx @@ -9,7 +9,7 @@ import { toast } from "somoto"; import type { AssetRef } from "@diffusionstudio/jsx"; import type { DocumentEditor } from "@/engine/editor"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; /** Between the variants of one batch, and between the batch and its neighbour. */ const GAP = 40; diff --git a/apps/web/src/components/genai/selection.ts b/apps/web/src/components/genai/selection.ts index 23a28d77..509396bc 100644 --- a/apps/web/src/components/genai/selection.ts +++ b/apps/web/src/components/genai/selection.ts @@ -19,7 +19,7 @@ import { useSelection } from "@/engine/hooks"; import { useLibrary } from "@/engine/library"; import type { Asset } from "@diffusionstudio/assets"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** A selected node and the library asset it is bound to. */ export interface BoundNode { diff --git a/apps/web/src/components/genai/use-auto-captions.tsx b/apps/web/src/components/genai/use-auto-captions.tsx index 385cdfa5..b453705e 100644 --- a/apps/web/src/components/genai/use-auto-captions.tsx +++ b/apps/web/src/components/genai/use-auto-captions.tsx @@ -5,11 +5,11 @@ import { createMemo } from "solid-js"; import { Captions, authoredElement } from "@diffusionstudio/reconciler"; import { Caption, getEntityTree, isScene } from "@diffusionstudio/runtime"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useEditor, useSelection } from "@/engine/hooks"; import { toast } from "somoto"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export function useAutoCaptions() { const world = useWorld(); diff --git a/apps/web/src/components/genai/use-generate-audio.tsx b/apps/web/src/components/genai/use-generate-audio.tsx index 1656bdb5..45479fba 100644 --- a/apps/web/src/components/genai/use-generate-audio.tsx +++ b/apps/web/src/components/genai/use-generate-audio.tsx @@ -7,7 +7,7 @@ import { generate } from "@diffusionstudio/jsx"; import { Audio } from "@diffusionstudio/reconciler"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useEditor } from "@/engine/hooks"; import { AUDIO_SIZE } from "@/engine/insert-asset"; import { insertGenerated, randomSeed } from "./insert"; diff --git a/apps/web/src/components/genai/use-generate-image.tsx b/apps/web/src/components/genai/use-generate-image.tsx index 7214bf4d..1d42d5ef 100644 --- a/apps/web/src/components/genai/use-generate-image.tsx +++ b/apps/web/src/components/genai/use-generate-image.tsx @@ -6,7 +6,7 @@ import { generate } from "@diffusionstudio/jsx"; import { ImagePaint, Rect } from "@diffusionstudio/reconciler"; import { Library } from "@diffusionstudio/runtime"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useEditor } from "@/engine/hooks"; import { ASPECT_RATIO_DIMENSIONS } from "./config"; import { insertGenerated, randomSeed } from "./insert"; diff --git a/apps/web/src/components/genai/use-generate-video.tsx b/apps/web/src/components/genai/use-generate-video.tsx index fb4f906d..206fb08e 100644 --- a/apps/web/src/components/genai/use-generate-video.tsx +++ b/apps/web/src/components/genai/use-generate-video.tsx @@ -5,7 +5,7 @@ import { generate } from "@diffusionstudio/jsx"; import { Rect, VideoPaint } from "@diffusionstudio/reconciler"; import { Library } from "@diffusionstudio/runtime"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useEditor } from "@/engine/hooks"; import { ASPECT_RATIO_DIMENSIONS } from "./config"; import { insertGenerated, randomSeed } from "./insert"; diff --git a/apps/web/src/components/genai/use-generate-voice.tsx b/apps/web/src/components/genai/use-generate-voice.tsx index 931f2d1f..df4922d4 100644 --- a/apps/web/src/components/genai/use-generate-voice.tsx +++ b/apps/web/src/components/genai/use-generate-voice.tsx @@ -8,7 +8,7 @@ import { generate } from "@diffusionstudio/jsx"; import { Audio } from "@diffusionstudio/reconciler"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useEditor } from "@/engine/hooks"; import { AUDIO_SIZE } from "@/engine/insert-asset"; import { insertGenerated, randomSeed } from "./insert"; diff --git a/apps/web/src/components/genai/use-modifiers.ts b/apps/web/src/components/genai/use-modifiers.ts index 17fc24d8..52e81e9b 100644 --- a/apps/web/src/components/genai/use-modifiers.ts +++ b/apps/web/src/components/genai/use-modifiers.ts @@ -13,7 +13,7 @@ import { authoredElement } from "@diffusionstudio/reconciler"; import { useEditor } from "@/engine/hooks"; import { useMediaSelection } from "./selection"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** The props an element carries to say what its source is put through. */ export type ModifierName = "removeBackground" | "upscale" | "addAudio"; diff --git a/apps/web/src/components/sidebar-left/asset-item.tsx b/apps/web/src/components/sidebar-left/asset-item.tsx index 4dd24361..54dcab13 100644 --- a/apps/web/src/components/sidebar-left/asset-item.tsx +++ b/apps/web/src/components/sidebar-left/asset-item.tsx @@ -4,7 +4,7 @@ import { createSignal, onMount, onCleanup, Show, createMemo } from "solid-js"; import { toast } from "somoto"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { assetName } from "@diffusionstudio/assets"; import { insertAssetAtPlayhead, replaceAssetSource, saveAssetAs } from "@/engine/asset-actions"; import { diff --git a/apps/web/src/components/sidebar-left/project-menu/edit-menu.tsx b/apps/web/src/components/sidebar-left/project-menu/edit-menu.tsx index c4c6970a..7901243f 100644 --- a/apps/web/src/components/sidebar-left/project-menu/edit-menu.tsx +++ b/apps/web/src/components/sidebar-left/project-menu/edit-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuGroup, } from "@/components/ui/dropdown-menu"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { copySelection, deleteSelection, diff --git a/apps/web/src/components/sidebar-left/project-menu/file-menu.tsx b/apps/web/src/components/sidebar-left/project-menu/file-menu.tsx index 24ebd839..2c8e9e46 100644 --- a/apps/web/src/components/sidebar-left/project-menu/file-menu.tsx +++ b/apps/web/src/components/sidebar-left/project-menu/file-menu.tsx @@ -19,7 +19,7 @@ import { forgetProjectBundle, generateProjectName } from "@/lib/db"; import { createProject, deleteProject, duplicateProject, ensureProjectsRoot } from "@/projects"; import { AssetId, ChildOf, Name, Root, Scene, getAssetFile, getActiveEntity, sortByItemIndex } from "@diffusionstudio/runtime"; import { assetName } from "@diffusionstudio/assets"; -import { useQuery, useWorld } from "@diffusionstudio/koota-solid"; +import { useQuery, useWorld } from "@diffusionstudio/koota/solid"; import { useLibrary } from "@/engine/library"; import { pickAndImport } from "@/engine/asset-actions"; import { projectRoute } from "@/hooks/use-project-route"; @@ -28,7 +28,7 @@ import { useExport } from "@/context/export"; import { getDefaultExportTemplate } from "@/components/sidebar-right/inspector/export-templates"; import { mimeTypeToExtension } from "@/utils"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export function FileMenu() { const navigate = useNavigate(); diff --git a/apps/web/src/components/sidebar-left/project-menu/object-menu.tsx b/apps/web/src/components/sidebar-left/project-menu/object-menu.tsx index e53305a7..23184496 100644 --- a/apps/web/src/components/sidebar-left/project-menu/object-menu.tsx +++ b/apps/web/src/components/sidebar-left/project-menu/object-menu.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSubTrigger, DropdownMenuGroup, } from "@/components/ui/dropdown-menu"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { isGroupLike, isSequence } from "@diffusionstudio/runtime"; import { groupSelection, diff --git a/apps/web/src/components/sidebar-left/project-menu/tool-menu.tsx b/apps/web/src/components/sidebar-left/project-menu/tool-menu.tsx index d17449f5..231afca2 100644 --- a/apps/web/src/components/sidebar-left/project-menu/tool-menu.tsx +++ b/apps/web/src/components/sidebar-left/project-menu/tool-menu.tsx @@ -9,7 +9,7 @@ import { DropdownMenuGroup, } from "@/components/ui/dropdown-menu"; import { usePromptInput } from "@/context/prompt-input"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Tool, ToolType } from "@diffusionstudio/runtime"; export function ToolMenu() { diff --git a/apps/web/src/components/sidebar-left/project-menu/view-menu.tsx b/apps/web/src/components/sidebar-left/project-menu/view-menu.tsx index f13ef7c2..84e6d444 100644 --- a/apps/web/src/components/sidebar-left/project-menu/view-menu.tsx +++ b/apps/web/src/components/sidebar-left/project-menu/view-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuGroup, } from "@/components/ui/dropdown-menu"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { useLayout } from "@/context/layout"; import { useSelection, zoomBy, zoomTo, zoomToFit, zoomToSelection } from "@/engine"; diff --git a/apps/web/src/components/sidebar-right/inspector/alignment.tsx b/apps/web/src/components/sidebar-right/inspector/alignment.tsx index b8eb443a..878e575a 100644 --- a/apps/web/src/components/sidebar-right/inspector/alignment.tsx +++ b/apps/web/src/components/sidebar-right/inspector/alignment.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { For } from "solid-js"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Icon } from "@/components/ui/icon"; import { alignSelection, distributeSelection } from "@/engine/align"; diff --git a/apps/web/src/components/sidebar-right/inspector/animations.tsx b/apps/web/src/components/sidebar-right/inspector/animations.tsx index 59380100..fae74238 100644 --- a/apps/web/src/components/sidebar-right/inspector/animations.tsx +++ b/apps/web/src/components/sidebar-right/inspector/animations.tsx @@ -25,7 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { SliderInput } from "@/components/ui/slider-input"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Animation as AnimationElement } from "@diffusionstudio/reconciler"; import { Animation, @@ -43,7 +43,7 @@ import { useDerived, useEditor } from "@/engine/hooks"; import { ANIMATION_GROUPS, DEFAULT_ANIMATION, animationOption } from "./animation-types"; import type { AnimationGroup, AnimationOption } from "./animation-types"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** ``'s defaults; a control left at one of these unsets its prop. */ const DEFAULT_DURATION = 1; diff --git a/apps/web/src/components/sidebar-right/inspector/appearance.tsx b/apps/web/src/components/sidebar-right/inspector/appearance.tsx index ab88f341..54a8e8df 100644 --- a/apps/web/src/components/sidebar-right/inspector/appearance.tsx +++ b/apps/web/src/components/sidebar-right/inspector/appearance.tsx @@ -25,7 +25,7 @@ import { Show, createMemo, createSignal } from "solid-js"; import { Icon } from "@/components/ui/icon"; import { SegmentedIconTabs } from "@/components/ui/segmented-icon-tabs"; import { Keyframe } from "@/components/ui/keyframe"; -import { useHas, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { BlendMode, BlendModeType, @@ -40,7 +40,7 @@ import { useDerived, useEditor } from "@/engine/hooks"; import { removeKeyframeTrack, syncKeyframe } from "@/engine/keyframes"; import { BLEND_MODE_ORDER, BLEND_MODE_SEPARATORS, blendModeName, displayBlendMode } from "./blend-modes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type AppearanceSettingsProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/asset-info.tsx b/apps/web/src/components/sidebar-right/inspector/asset-info.tsx index 21f177c6..0c4c995b 100644 --- a/apps/web/src/components/sidebar-right/inspector/asset-info.tsx +++ b/apps/web/src/components/sidebar-right/inspector/asset-info.tsx @@ -15,7 +15,7 @@ import { import { AssetInfoPreview } from "./asset-info-preview"; import { supabase } from "@/lib/supabase"; import { toClientConfig } from "@/components/genai/use-generation-records"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { assetName } from "@diffusionstudio/assets"; import { useLibrary } from "@/engine/library"; import { useAssetSelection } from "@/engine/hooks"; diff --git a/apps/web/src/components/sidebar-right/inspector/asset-picker.tsx b/apps/web/src/components/sidebar-right/inspector/asset-picker.tsx index 01e75e8a..94614f1f 100644 --- a/apps/web/src/components/sidebar-right/inspector/asset-picker.tsx +++ b/apps/web/src/components/sidebar-right/inspector/asset-picker.tsx @@ -20,13 +20,13 @@ import { LazyAssetItem } from "@/components/sidebar-left/asset-item"; import { usePromptInput } from "@/context/prompt-input"; import { createDefaultConfig } from "@/components/genai/prompt-input"; import { assetName } from "@diffusionstudio/assets"; -import { useTrait } from "@diffusionstudio/koota-solid"; +import { useTrait } from "@diffusionstudio/koota/solid"; import { AssetId, isScene } from "@diffusionstudio/runtime"; import { useLibrary } from "@/engine/library"; import { pickAndImport } from "@/engine/asset-actions"; import type { Asset } from "@diffusionstudio/assets"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type AssetFilter = "ALL" | "IMAGE" | "VIDEO"; diff --git a/apps/web/src/components/sidebar-right/inspector/audio.tsx b/apps/web/src/components/sidebar-right/inspector/audio.tsx index c577552f..84194b7c 100644 --- a/apps/web/src/components/sidebar-right/inspector/audio.tsx +++ b/apps/web/src/components/sidebar-right/inspector/audio.tsx @@ -10,12 +10,12 @@ import { ControlledTextField } from "@/components/ui/text-field"; import { Keyframe } from "@/components/ui/keyframe"; import { useVolumeMeter, type ChannelLevels } from "@/hooks/use-volume-meter"; import { cx } from "@/lib/cva"; -import { useHas, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { AudioBusHandle, Computed, Muted, Soloed } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; const MIN_DB = -60; const MAX_DB = 12; diff --git a/apps/web/src/components/sidebar-right/inspector/background.tsx b/apps/web/src/components/sidebar-right/inspector/background.tsx index 92582308..bb225700 100644 --- a/apps/web/src/components/sidebar-right/inspector/background.tsx +++ b/apps/web/src/components/sidebar-right/inspector/background.tsx @@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { createSignal } from "solid-js"; import { Background, DEFAULT_BACKGROUND, Root, colorToHex } from "@diffusionstudio/runtime"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { useEditor } from "@/engine/hooks/use-editor"; export function BackgroundSettings() { diff --git a/apps/web/src/components/sidebar-right/inspector/caption-settings.tsx b/apps/web/src/components/sidebar-right/inspector/caption-settings.tsx index 659c7a69..50a4b6ad 100644 --- a/apps/web/src/components/sidebar-right/inspector/caption-settings.tsx +++ b/apps/web/src/components/sidebar-right/inspector/caption-settings.tsx @@ -24,7 +24,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ColorOpacityRow } from "@/components/ui/color-opacity-row"; import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; -import { useTrait } from "@diffusionstudio/koota-solid"; +import { useTrait } from "@diffusionstudio/koota/solid"; import { Caption, colorToHex } from "@diffusionstudio/runtime"; import { useEditor } from "@/engine/hooks"; import { @@ -34,7 +34,7 @@ import { } from "./caption-types"; import type { CaptionPresetOption } from "./caption-types"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type CaptionSettingsProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/effects-inspector.tsx b/apps/web/src/components/sidebar-right/inspector/effects-inspector.tsx index 2b148f23..7d2e97f0 100644 --- a/apps/web/src/components/sidebar-right/inspector/effects-inspector.tsx +++ b/apps/web/src/components/sidebar-right/inspector/effects-inspector.tsx @@ -25,14 +25,14 @@ import { } from "@/components/ui/select"; import { SliderInput } from "@/components/ui/slider-input"; import { ControlledTextField } from "@/components/ui/text-field"; -import { useHas, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Computed, Effect, Hidden } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { removeKeyframeTrack, syncKeyframe } from "@/engine/keyframes"; import { EFFECT_OPTIONS, effectOption } from "./effect-types"; import type { EffectOption } from "./effect-types"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; const clampUnit = (value: number) => Math.min(1, Math.max(0, value)); diff --git a/apps/web/src/components/sidebar-right/inspector/effects.tsx b/apps/web/src/components/sidebar-right/inspector/effects.tsx index b66e015e..43d3968f 100644 --- a/apps/web/src/components/sidebar-right/inspector/effects.tsx +++ b/apps/web/src/components/sidebar-right/inspector/effects.tsx @@ -15,14 +15,14 @@ import { import { Icon } from "@/components/ui/icon"; import { ItemRow } from "@/components/ui/item-row"; import { PanelSection } from "@/components/ui/panel-section"; -import { useHas, useTrait } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait } from "@diffusionstudio/koota/solid"; import { Effect as EffectElement } from "@diffusionstudio/reconciler"; import { Cache, Effect, Hidden } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { DEFAULT_EFFECT, effectOption } from "./effect-types"; import { EffectsInspector } from "./effects-inspector"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; // Stable identity, so a node without effects does not resample every tick. const NO_EFFECTS: Entity[] = []; diff --git a/apps/web/src/components/sidebar-right/inspector/export.tsx b/apps/web/src/components/sidebar-right/inspector/export.tsx index bd3d46ed..f8f45742 100644 --- a/apps/web/src/components/sidebar-right/inspector/export.tsx +++ b/apps/web/src/components/sidebar-right/inspector/export.tsx @@ -27,7 +27,7 @@ import { Switch, SwitchControl, SwitchInput, SwitchThumb } from "@/components/ui import { Separator } from "@/components/ui/separator"; import { SliderInput } from "@/components/ui/slider-input"; import { formatBytes, formatDuration } from "@/utils/formatters"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Computed, FrameRate, Source } from "@diffusionstudio/runtime"; import { useDerived } from "@/engine/hooks"; import { useProjectConfig } from "@/engine/project-config"; @@ -43,7 +43,7 @@ import { SAMPLE_RATE_OPTIONS, } from "./export-templates"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { VideoCodec, AudioCodec } from "mediabunny"; import type { ContainerFormat, ExportConfig as ProjectExportConfig } from "@/engine/project-config"; import type { ExportConfig } from "./export-templates"; diff --git a/apps/web/src/components/sidebar-right/inspector/fill-picker.tsx b/apps/web/src/components/sidebar-right/inspector/fill-picker.tsx index 9dfdd5cd..1aef4be2 100644 --- a/apps/web/src/components/sidebar-right/inspector/fill-picker.tsx +++ b/apps/web/src/components/sidebar-right/inspector/fill-picker.tsx @@ -21,7 +21,7 @@ import { FloatingInspectorHeader, FloatingInspectorTitle, } from "@/components/ui/floating-inspector"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { ColorStop as ColorStopElement, ImagePaint, @@ -40,7 +40,7 @@ import { AssetFillPicker } from "./asset-picker"; import type { Fit } from "@diffusionstudio/jsx"; import type { Asset } from "@diffusionstudio/assets"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type FillTab = "solid" | "gradient" | "asset"; diff --git a/apps/web/src/components/sidebar-right/inspector/fill-row.tsx b/apps/web/src/components/sidebar-right/inspector/fill-row.tsx index 76ebe927..f7b5630c 100644 --- a/apps/web/src/components/sidebar-right/inspector/fill-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/fill-row.tsx @@ -12,11 +12,11 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { useHas, useTrait } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait } from "@diffusionstudio/koota/solid"; import { Hidden, Paint, PaintType } from "@diffusionstudio/runtime"; import { useEditor } from "@/engine/hooks"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type FillRowProps = { fill: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/fills.tsx b/apps/web/src/components/sidebar-right/inspector/fills.tsx index 21393fd3..db223b8c 100644 --- a/apps/web/src/components/sidebar-right/inspector/fills.tsx +++ b/apps/web/src/components/sidebar-right/inspector/fills.tsx @@ -13,7 +13,7 @@ import { useDerived, useEditor } from "@/engine/hooks"; import { FillPicker, type FillTab } from "./fill-picker"; import { FillRow } from "./fill-row"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** What "Add fill" authors. */ const DEFAULT_FILL_COLOR = "#E0E0E0"; diff --git a/apps/web/src/components/sidebar-right/inspector/gradient-picker.tsx b/apps/web/src/components/sidebar-right/inspector/gradient-picker.tsx index 0a06632a..fc8ea0bf 100644 --- a/apps/web/src/components/sidebar-right/inspector/gradient-picker.tsx +++ b/apps/web/src/components/sidebar-right/inspector/gradient-picker.tsx @@ -35,14 +35,14 @@ import { import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; import { useDrag } from "@/hooks/use-drag"; import { clamp, mergeColorWithOpacity } from "@/utils"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { ColorStop as ColorStopElement } from "@diffusionstudio/reconciler"; import { Computed, Paint, PaintType, colorToHex, parseColor } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; import { readGradientStops, sameGradientStops, type GradientStop } from "./gradient-stops"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type GradientPickerProps = { fill: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/gradient-stops.ts b/apps/web/src/components/sidebar-right/inspector/gradient-stops.ts index d5449973..90b8892a 100644 --- a/apps/web/src/components/sidebar-right/inspector/gradient-stops.ts +++ b/apps/web/src/components/sidebar-right/inspector/gradient-stops.ts @@ -4,7 +4,7 @@ import { ChildOf, ColorStop, Computed, colorToHex } from "@diffusionstudio/runtime"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; export type GradientStop = { entity: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/inspector-header.tsx b/apps/web/src/components/sidebar-right/inspector/inspector-header.tsx index f775a305..d81f2e98 100644 --- a/apps/web/src/components/sidebar-right/inspector/inspector-header.tsx +++ b/apps/web/src/components/sidebar-right/inspector/inspector-header.tsx @@ -12,15 +12,21 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; -import { useWorld } from "@diffusionstudio/koota-solid"; -import { useCameraScale, zoomBy, zoomTo, zoomToFit } from "@/engine"; +import { useWorld, useTrait } from "@diffusionstudio/koota/solid"; +import { zoomBy, zoomTo, zoomToFit } from "@/engine"; +import { Camera, Root } from '@diffusionstudio/runtime'; +import { createMemo, } from 'solid-js'; export function InspectorHeader() { const world = useWorld(); - // Every operation here is anchored on the stage itself — its center, or its - // content — so the menu needs no canvas geometry of its own. - const scale = useCameraScale(); + const camera = useTrait(world.get(Root)!, Camera); + + const scale = createMemo(() => { + const { a = 1, b = 0 } = camera() ?? {}; + return Math.hypot(a, b); + }); + const zoomLabel = () => `${Math.round(scale() * 100)}%`; return ( diff --git a/apps/web/src/components/sidebar-right/inspector/inspector.tsx b/apps/web/src/components/sidebar-right/inspector/inspector.tsx index 62270225..09331bd6 100644 --- a/apps/web/src/components/sidebar-right/inspector/inspector.tsx +++ b/apps/web/src/components/sidebar-right/inspector/inspector.tsx @@ -41,7 +41,7 @@ import { MasksSettings } from "./masks"; import { AudioSettings } from "./audio"; import { InterpolationSettings } from "./interpolation"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type SelectionTarget = | "scene-tool" diff --git a/apps/web/src/components/sidebar-right/inspector/interpolation.tsx b/apps/web/src/components/sidebar-right/inspector/interpolation.tsx index b86bd6f9..fdf39979 100644 --- a/apps/web/src/components/sidebar-right/inspector/interpolation.tsx +++ b/apps/web/src/components/sidebar-right/inspector/interpolation.tsx @@ -17,7 +17,7 @@ import { SelectValue, } from "@/components/ui/select"; import { SegmentedIconTabs } from "@/components/ui/segmented-icon-tabs"; -import { useTrait } from "@diffusionstudio/koota-solid"; +import { useTrait } from "@diffusionstudio/koota/solid"; import { Keyframe } from "@diffusionstudio/runtime"; import { useEditor } from "@/engine/hooks"; import { @@ -29,7 +29,7 @@ import { } from "./easing-types"; import type { EasingPreset } from "./easing-types"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type InterpolationSettingsProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/layout.tsx b/apps/web/src/components/sidebar-right/inspector/layout.tsx index 935597cf..3b846c87 100644 --- a/apps/web/src/components/sidebar-right/inspector/layout.tsx +++ b/apps/web/src/components/sidebar-right/inspector/layout.tsx @@ -16,7 +16,7 @@ import { CheckboxLabel, } from "@/components/ui/checkbox"; import { createMemo, For, Show } from "solid-js"; -import { useHas, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useWorld } from "@diffusionstudio/koota/solid"; import { ClipsContent, Computed, KeepAspectRatio, isScene } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; @@ -34,7 +34,7 @@ import { import { PRESET_CATEGORIES } from "@/lib/layout-presets"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type LayoutPanelProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/masks.tsx b/apps/web/src/components/sidebar-right/inspector/masks.tsx index afadfb04..69bca555 100644 --- a/apps/web/src/components/sidebar-right/inspector/masks.tsx +++ b/apps/web/src/components/sidebar-right/inspector/masks.tsx @@ -8,12 +8,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Icon } from "@/components/ui/icon"; import { ItemRow } from "@/components/ui/item-row"; import { PanelSection } from "@/components/ui/panel-section"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Rect } from "@diffusionstudio/reconciler"; import { Cache, Computed, Name, getNextName } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** * Where a new mask sits in its parent, px. Offset rather than flush so the diff --git a/apps/web/src/components/sidebar-right/inspector/scene-template.tsx b/apps/web/src/components/sidebar-right/inspector/scene-template.tsx index 2aa1af17..11043460 100644 --- a/apps/web/src/components/sidebar-right/inspector/scene-template.tsx +++ b/apps/web/src/components/sidebar-right/inspector/scene-template.tsx @@ -5,7 +5,7 @@ import { For, Show, createSignal } from "solid-js"; import { Icon } from "@/components/ui/icon"; import { PanelSection } from "@/components/ui/panel-section"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Scene, SolidPaint } from "@diffusionstudio/reconciler"; import { Root, Source, Tool, ToolType, getNextName, getViewport, screenToWorld } from "@diffusionstudio/runtime"; import { useEditor } from "@/engine/hooks"; diff --git a/apps/web/src/components/sidebar-right/inspector/shadow-inspector.tsx b/apps/web/src/components/sidebar-right/inspector/shadow-inspector.tsx index 826a9842..512d32d6 100644 --- a/apps/web/src/components/sidebar-right/inspector/shadow-inspector.tsx +++ b/apps/web/src/components/sidebar-right/inspector/shadow-inspector.tsx @@ -19,12 +19,12 @@ import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; import { ControlledTextField } from "@/components/ui/text-field"; import { IncrementDecrementControl } from "@/components/ui/increment-decrement-control"; import { Keyframe } from "@/components/ui/keyframe"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed, colorToHex } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** ``'s defaults; a control left at one of these unsets its prop. */ const DEFAULT_OPACITY = 1; diff --git a/apps/web/src/components/sidebar-right/inspector/shadows.tsx b/apps/web/src/components/sidebar-right/inspector/shadows.tsx index e440b212..c8eb0494 100644 --- a/apps/web/src/components/sidebar-right/inspector/shadows.tsx +++ b/apps/web/src/components/sidebar-right/inspector/shadows.tsx @@ -15,13 +15,13 @@ import { import { Icon } from "@/components/ui/icon"; import { ItemRow } from "@/components/ui/item-row"; import { PanelSection } from "@/components/ui/panel-section"; -import { useHas } from "@diffusionstudio/koota-solid"; +import { useHas } from "@diffusionstudio/koota/solid"; import { Shadow as ShadowElement } from "@diffusionstudio/reconciler"; import { Cache, Computed, Hidden, colorToHex } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { ShadowInspector } from "./shadow-inspector"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** * What "Add shadow" authors. ``'s own defaults are all zero, which diff --git a/apps/web/src/components/sidebar-right/inspector/solid-picker.tsx b/apps/web/src/components/sidebar-right/inspector/solid-picker.tsx index 38efc714..d3c933db 100644 --- a/apps/web/src/components/sidebar-right/inspector/solid-picker.tsx +++ b/apps/web/src/components/sidebar-right/inspector/solid-picker.tsx @@ -3,12 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed, colorToHex } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type SolidFillPickerProps = { fill: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/source.tsx b/apps/web/src/components/sidebar-right/inspector/source.tsx index f8678631..9106cac4 100644 --- a/apps/web/src/components/sidebar-right/inspector/source.tsx +++ b/apps/web/src/components/sidebar-right/inspector/source.tsx @@ -18,7 +18,7 @@ import { AssetThumbnail } from "@/components/ui/asset-thumbnail"; import { OpacitySwatch } from "@/components/ui/opacity-swatch"; import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; import { assetName } from "@diffusionstudio/assets"; -import { useHas, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { AssetId, Color, Computed, PaintType, colorToHex, getIntrinsicPaint, parseColor } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; @@ -27,7 +27,7 @@ import { FitMenu } from "./fill-picker"; import { AssetFillPicker } from "./asset-picker"; import type { Asset } from "@diffusionstudio/assets"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** The row label, like a fill row's: which kind of media the node is. */ const MEDIA_LABELS: Partial> = { diff --git a/apps/web/src/components/sidebar-right/inspector/stroke-inspector.tsx b/apps/web/src/components/sidebar-right/inspector/stroke-inspector.tsx index dfaaa678..397ed944 100644 --- a/apps/web/src/components/sidebar-right/inspector/stroke-inspector.tsx +++ b/apps/web/src/components/sidebar-right/inspector/stroke-inspector.tsx @@ -19,13 +19,13 @@ import { ColorOpacityPicker } from "@/components/ui/color-opacity-picker"; import { ControlledTextField } from "@/components/ui/text-field"; import { SegmentedIconTabs } from "@/components/ui/segmented-icon-tabs"; import { Keyframe } from "@/components/ui/keyframe"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Computed, StrokeJoin, StrokeStyle, colorToHex } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; import type { StrokeJoin as StrokeJoinName } from "@diffusionstudio/jsx"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; const JOIN_SEGMENTS: { value: StrokeJoinName; icon: string; label: string }[] = [ { value: "miter", icon: "line-join-miter", label: "Miter" }, diff --git a/apps/web/src/components/sidebar-right/inspector/strokes.tsx b/apps/web/src/components/sidebar-right/inspector/strokes.tsx index 71093e28..c04573f2 100644 --- a/apps/web/src/components/sidebar-right/inspector/strokes.tsx +++ b/apps/web/src/components/sidebar-right/inspector/strokes.tsx @@ -15,13 +15,13 @@ import { import { Icon } from "@/components/ui/icon"; import { ItemRow } from "@/components/ui/item-row"; import { PanelSection } from "@/components/ui/panel-section"; -import { useHas } from "@diffusionstudio/koota-solid"; +import { useHas } from "@diffusionstudio/koota/solid"; import { Stroke as StrokeElement } from "@diffusionstudio/reconciler"; import { Cache, Computed, Hidden, colorToHex } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { StrokeInspector } from "./stroke-inspector"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; /** What "Add stroke" authors; ``'s own default color. */ const DEFAULT_COLOR = "#000000"; diff --git a/apps/web/src/components/sidebar-right/inspector/text.tsx b/apps/web/src/components/sidebar-right/inspector/text.tsx index fd8e4b89..f0b1649a 100644 --- a/apps/web/src/components/sidebar-right/inspector/text.tsx +++ b/apps/web/src/components/sidebar-right/inspector/text.tsx @@ -30,7 +30,7 @@ import { Button } from '@/components/ui/button'; import { cx } from '@/lib/cva'; import { toast } from 'somoto'; import { usePermissionState } from '@/hooks/use-permission'; -import { useHas, useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useHas, useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { Chars, Computed, @@ -51,7 +51,7 @@ import { getLocalFonts } from '@/engine/fonts'; import { useDerived, useEditor, useTool } from '@/engine/hooks'; import { removeKeyframeTrack, syncKeyframe } from '@/engine/keyframes'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; type TextPanelProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/time.tsx b/apps/web/src/components/sidebar-right/inspector/time.tsx index fe2c1bf1..b5bda5b3 100644 --- a/apps/web/src/components/sidebar-right/inspector/time.tsx +++ b/apps/web/src/components/sidebar-right/inspector/time.tsx @@ -30,7 +30,7 @@ import { } from "@/components/ui/context-menu"; import { createStoredSignal } from "@/lib/store"; import { store } from "@/init"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { Computed, FrameRate, @@ -48,7 +48,7 @@ import { import { useDerived, useEditor } from "@/engine/hooks"; import { editTime, trimIn, trimOut } from "@/engine/timing"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type TimeAddon = "inOut" | "playbackRate"; type TimeAddons = Partial>; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/anchor-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/anchor-row.tsx index 8711ba50..47a253d9 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/anchor-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/anchor-row.tsx @@ -12,11 +12,11 @@ import { ContextMenuContent, ContextMenuItem, } from "@/components/ui/context-menu"; -import { useTrait } from "@diffusionstudio/koota-solid"; +import { useTrait } from "@diffusionstudio/koota/solid"; import { Anchor } from "@diffusionstudio/runtime"; import { AnchorPointPicker } from "./anchor-picker"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type AnchorRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/constraints-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/constraints-row.tsx index 8b528017..41458e37 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/constraints-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/constraints-row.tsx @@ -12,10 +12,10 @@ import { SelectPortal, } from "@/components/ui/select"; import { horizontalConstraints, verticalConstraints } from "./constants"; -import { useTrait } from "@diffusionstudio/koota-solid"; +import { useTrait } from "@diffusionstudio/koota/solid"; import { Constraint, ConstraintType } from "@diffusionstudio/runtime"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type ConstraintsRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/offset-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/offset-row.tsx index ace6e379..f5793ea0 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/offset-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/offset-row.tsx @@ -13,12 +13,12 @@ import { ContextMenuItem, } from "@/components/ui/context-menu"; import { Keyframe } from "@/components/ui/keyframe"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { syncKeyframe } from "@/engine/keyframes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type OffsetRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/rotate-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/rotate-row.tsx index ababbd50..8cc0c8c0 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/rotate-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/rotate-row.tsx @@ -14,12 +14,12 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu'; import { Keyframe } from '@/components/ui/keyframe'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { Computed, Flip } from '@diffusionstudio/runtime'; import { useDerived, useEditor } from '@/engine/hooks'; import { syncKeyframe } from '@/engine/keyframes'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; type RotateRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/scale-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/scale-row.tsx index bc737884..a1ecf220 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/scale-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/scale-row.tsx @@ -14,12 +14,12 @@ import { } from "@/components/ui/context-menu"; import { SegmentedIconTabs } from "@/components/ui/segmented-icon-tabs"; import { Keyframe } from "@/components/ui/keyframe"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed, Scale, UniformScale } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { removeKeyframeTrack, syncKeyframe } from "@/engine/keyframes"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type ScaleRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/skew-row.tsx b/apps/web/src/components/sidebar-right/inspector/transform/skew-row.tsx index 02871e96..ded9dd22 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/skew-row.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/skew-row.tsx @@ -15,7 +15,7 @@ import { import { Computed, Skew } from "@diffusionstudio/runtime"; import { useDerived } from "@/engine/hooks"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; export type SkewRowProps = { node: Entity; diff --git a/apps/web/src/components/sidebar-right/inspector/transform/transform-settings.tsx b/apps/web/src/components/sidebar-right/inspector/transform/transform-settings.tsx index 5a6bc319..48495f6b 100644 --- a/apps/web/src/components/sidebar-right/inspector/transform/transform-settings.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transform/transform-settings.tsx @@ -16,7 +16,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { Keyframe } from "@/components/ui/keyframe"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed, getParentEntity, @@ -35,7 +35,7 @@ import { ConstraintsRow } from "./constraints-row"; import { createStoredSignal } from "@/lib/store"; import { store } from "@/init"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type TransformSettingsProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/inspector/transition.tsx b/apps/web/src/components/sidebar-right/inspector/transition.tsx index 491b2f2c..38c430d1 100644 --- a/apps/web/src/components/sidebar-right/inspector/transition.tsx +++ b/apps/web/src/components/sidebar-right/inspector/transition.tsx @@ -24,7 +24,7 @@ import { SelectValue, } from "@/components/ui/select"; import { SliderInput } from "@/components/ui/slider-input"; -import { useHas, useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { FrameRate, Transition, framesToSeconds } from "@diffusionstudio/runtime"; import { useEditor } from "@/engine/hooks"; import { @@ -35,7 +35,7 @@ import { } from "./transition-types"; import type { TransitionOption } from "./transition-types"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type TransitionSettingsProps = { selection: Entity[]; diff --git a/apps/web/src/components/sidebar-right/soundboard/soundboard.tsx b/apps/web/src/components/sidebar-right/soundboard/soundboard.tsx index 25f1eaa3..ee725095 100644 --- a/apps/web/src/components/sidebar-right/soundboard/soundboard.tsx +++ b/apps/web/src/components/sidebar-right/soundboard/soundboard.tsx @@ -12,13 +12,13 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useQuery, useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useQuery, useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { AudioBusHandle, Computed, Name, Volume } from '@diffusionstudio/runtime'; -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { useDerived, useEditor, useTimelineIndex } from '@/engine/hooks'; import { syncKeyframe } from '@/engine/keyframes'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; /** A layer's name, following a rename. */ function LayerName(props: { entity: Entity | undefined }) { diff --git a/apps/web/src/components/timeline/layers/context.tsx b/apps/web/src/components/timeline/layers/context.tsx index 894f84e2..f54626d9 100644 --- a/apps/web/src/components/timeline/layers/context.tsx +++ b/apps/web/src/components/timeline/layers/context.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createContext, createSignal, onCleanup, useContext, type JSX, type Signal } from "solid-js"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Expanded, getEntityTree, isMask, isSequence } from "@diffusionstudio/runtime"; import { useEditor, useTimelineIndex } from "@/engine/hooks"; @@ -12,7 +12,7 @@ import { resolveSequentialOverlaps } from "@/engine/overlap"; import { assert, clamp } from "@/utils"; import { flattenRows, getListBottom, hitRows, resolveGap } from "./drag"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { DropTarget, FlatRow, GapContext, LayerDrag } from "./drag"; /** How far the pointer travels before a press means a drag, not a click. */ diff --git a/apps/web/src/components/timeline/layers/drag.ts b/apps/web/src/components/timeline/layers/drag.ts index 1814a341..1c72d750 100644 --- a/apps/web/src/components/timeline/layers/drag.ts +++ b/apps/web/src/components/timeline/layers/drag.ts @@ -17,7 +17,7 @@ import { isGroupLike, isMask, isSequence } from '@diffusionstudio/runtime'; import { NESTED_INDENT_PX } from './config'; import type { Accessor } from 'solid-js'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; import type { TimelineNode } from '@diffusionstudio/runtime'; /** Client X of the insertion dot for a depth-0 drop; each depth steps inwards. diff --git a/apps/web/src/components/timeline/layers/hover.ts b/apps/web/src/components/timeline/layers/hover.ts index b0ab76d6..9d978e82 100644 --- a/apps/web/src/components/timeline/layers/hover.ts +++ b/apps/web/src/components/timeline/layers/hover.ts @@ -4,7 +4,7 @@ import { Hovering } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Points the hover at one row, or at none. Only ever one row is hovered, so diff --git a/apps/web/src/components/timeline/layers/keyframe.tsx b/apps/web/src/components/timeline/layers/keyframe.tsx index ac5f43c1..0f759d72 100644 --- a/apps/web/src/components/timeline/layers/keyframe.tsx +++ b/apps/web/src/components/timeline/layers/keyframe.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createMemo, Show } from 'solid-js'; -import { useTag, useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTag, useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { trackProperty } from '@diffusionstudio/reconciler'; import { Cache, @@ -25,7 +25,7 @@ import { KEYFRAME_TRACK_HEIGHT } from '@/engine/timeline'; import { NESTED_INDENT_PX } from './config'; import { setRowHover } from './hover'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; import type { PropertyPath } from '@diffusionstudio/runtime'; import type { LayerRowProps } from './layer'; diff --git a/apps/web/src/components/timeline/layers/layer.tsx b/apps/web/src/components/timeline/layers/layer.tsx index 7b622883..95fa976c 100644 --- a/apps/web/src/components/timeline/layers/layer.tsx +++ b/apps/web/src/components/timeline/layers/layer.tsx @@ -4,7 +4,7 @@ import { Index } from 'solid-js'; import { Dynamic } from 'solid-js/web'; -import { useTag } from '@diffusionstudio/koota-solid'; +import { useTag } from '@diffusionstudio/koota/solid'; import { Selected } from '@diffusionstudio/runtime'; import { KeyframeLayer } from './keyframe'; import { NodeLayer } from './node'; diff --git a/apps/web/src/components/timeline/layers/layers.tsx b/apps/web/src/components/timeline/layers/layers.tsx index b90e660d..25af26c9 100644 --- a/apps/web/src/components/timeline/layers/layers.tsx +++ b/apps/web/src/components/timeline/layers/layers.tsx @@ -4,7 +4,7 @@ import { createEffect, createMemo, Index, onCleanup, onMount, Show } from 'solid-js'; import { toast } from 'somoto'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { Sequence as SequenceElement } from '@diffusionstudio/reconciler'; import { ClipHeight, @@ -45,7 +45,7 @@ import { DropIndicator } from './drop-indicator'; import { formatFrames, TIME_FORMAT_OPTIONS, type TimeFormat } from '../time-format'; import type { TimelineNode } from '@diffusionstudio/runtime'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; /** The row heights the height menu offers, tightest first. */ const HEIGHT_PRESETS = [ diff --git a/apps/web/src/components/timeline/layers/node.tsx b/apps/web/src/components/timeline/layers/node.tsx index 6e440a6d..af5c9531 100644 --- a/apps/web/src/components/timeline/layers/node.tsx +++ b/apps/web/src/components/timeline/layers/node.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createMemo, createSignal, Show } from 'solid-js'; -import { useTag, useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTag, useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { ClipHeight, Expanded, @@ -42,7 +42,7 @@ import { NESTED_INDENT_PX } from './config'; import { useLayerContext } from './context'; import { setRowHover } from './hover'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { TimelineNode } from '@diffusionstudio/runtime'; import type { LayerRowProps } from './layer'; diff --git a/apps/web/src/components/timeline/layers/sub-item.tsx b/apps/web/src/components/timeline/layers/sub-item.tsx index 403a1f9b..c84f1094 100644 --- a/apps/web/src/components/timeline/layers/sub-item.tsx +++ b/apps/web/src/components/timeline/layers/sub-item.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createMemo, Show } from 'solid-js'; -import { useTag, useWorld } from '@diffusionstudio/koota-solid'; +import { useTag, useWorld } from '@diffusionstudio/koota/solid'; import { ColorStop, Effect, @@ -23,7 +23,7 @@ import { effectOption } from '@/components/sidebar-right/inspector/effect-types' import { NESTED_INDENT_PX } from './config'; import { setRowHover } from './hover'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; import type { LayerRowProps } from './layer'; const PAINT_NAMES: Partial> = { diff --git a/apps/web/src/components/timeline/timeline.tsx b/apps/web/src/components/timeline/timeline.tsx index 77064060..7bf9c67f 100644 --- a/apps/web/src/components/timeline/timeline.tsx +++ b/apps/web/src/components/timeline/timeline.tsx @@ -4,7 +4,7 @@ import { onCleanup, onMount } from 'solid-js'; import { toast } from 'somoto'; -import { useWorld } from '@diffusionstudio/koota-solid'; +import { useWorld } from '@diffusionstudio/koota/solid'; import { FrameRate, framesToSeconds, getActiveEntity } from '@diffusionstudio/runtime'; import { droppedFiles, importFiles } from '@/engine/asset-actions'; import { insertAsset } from '@/engine/insert-asset'; diff --git a/apps/web/src/components/ui/color-opacity-picker.tsx b/apps/web/src/components/ui/color-opacity-picker.tsx index 30034479..b0e82c15 100644 --- a/apps/web/src/components/ui/color-opacity-picker.tsx +++ b/apps/web/src/components/ui/color-opacity-picker.tsx @@ -30,11 +30,11 @@ import { type Accessor, type Setter, } from "solid-js"; -import { useQuery } from "@diffusionstudio/koota-solid"; +import { useQuery } from "@diffusionstudio/koota/solid"; import { Color, DEFAULT_BACKGROUND } from "@diffusionstudio/runtime"; import { toast } from "somoto"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; const COLOR_TYPES = ["Hex", "HSL", "RGB"] as const; type ColorType = (typeof COLOR_TYPES)[number]; diff --git a/apps/web/src/components/ui/fill-item.tsx b/apps/web/src/components/ui/fill-item.tsx index a8c9121b..42a6704b 100644 --- a/apps/web/src/components/ui/fill-item.tsx +++ b/apps/web/src/components/ui/fill-item.tsx @@ -7,7 +7,7 @@ import { AssetThumbnail } from "@/components/ui/asset-thumbnail"; import { Icon } from "@/components/ui/icon"; import { Keyframe } from "@/components/ui/keyframe"; import { OpacitySwatch } from "@/components/ui/opacity-swatch"; -import { useTrait, useWorld } from "@diffusionstudio/koota-solid"; +import { useTrait, useWorld } from "@diffusionstudio/koota/solid"; import { AssetId, Computed, Paint, PaintType, colorToHex, parseColor } from "@diffusionstudio/runtime"; import { useDerived, useEditor } from "@/engine/hooks"; import { useLibrary } from "@/engine/library"; @@ -16,7 +16,7 @@ import { readGradientStops, sameGradientStops, type GradientStop } from "@/compo import { assetName } from "@diffusionstudio/assets"; import { mergeColorWithOpacity } from "@/utils"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; const DRAG_THRESHOLD_PX = 3; diff --git a/apps/web/src/components/ui/keyframe.tsx b/apps/web/src/components/ui/keyframe.tsx index e343e303..e67d4ac7 100644 --- a/apps/web/src/components/ui/keyframe.tsx +++ b/apps/web/src/components/ui/keyframe.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Show } from "solid-js"; -import { useHas, useWorld } from "@diffusionstudio/koota-solid"; +import { useHas, useWorld } from "@diffusionstudio/koota/solid"; import { Cache, Source } from "@diffusionstudio/runtime"; import { cx } from "@/lib/cva"; import { useDerived, useEditor } from "@/engine/hooks"; @@ -11,7 +11,7 @@ import { findKeyframeAt, findKeyframeTrack, keyframeFrame, toggleKeyframe } from import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; import type { AnimatableProperty } from "@diffusionstudio/jsx"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; type KeyframeProps = { target: Entity; diff --git a/apps/web/src/context/dapi/api.tsx b/apps/web/src/context/dapi/api.tsx index 71a70e6b..5f59c0f9 100644 --- a/apps/web/src/context/dapi/api.tsx +++ b/apps/web/src/context/dapi/api.tsx @@ -4,7 +4,7 @@ import { createEffect, createContext, useContext, onCleanup } from "solid-js"; import { useNavigate } from "@solidjs/router"; -import { useWorld } from '@diffusionstudio/koota-solid'; +import { useWorld } from '@diffusionstudio/koota/solid'; import { Project } from '@diffusionstudio/runtime'; import { useProject } from '@/context/project'; import { useAuth } from '@/context/auth'; diff --git a/apps/web/src/context/dapi/check.ts b/apps/web/src/context/dapi/check.ts index 15a59505..3c61bc32 100644 --- a/apps/web/src/context/dapi/check.ts +++ b/apps/web/src/context/dapi/check.ts @@ -11,7 +11,7 @@ import { import { resolveNode } from "./nodes"; import type { CheckIssue, CheckRequest, CheckResult } from "@diffusionstudio/cli/channels"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { EditorSession } from "./session"; // Absolute frames, [start, end). diff --git a/apps/web/src/context/dapi/context.ts b/apps/web/src/context/dapi/context.ts index 1f6a7bb8..8c070a45 100644 --- a/apps/web/src/context/dapi/context.ts +++ b/apps/web/src/context/dapi/context.ts @@ -7,7 +7,7 @@ import { AssetId, Computed, Fonts, FrameRate, Generating, getActiveEntity, Libra import { getProjectsRoot } from "@/projects"; import type { Accessor } from "solid-js"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; import type { EditorSession } from "./session"; /** diff --git a/apps/web/src/context/dapi/nodes.ts b/apps/web/src/context/dapi/nodes.ts index 690fb67d..ecd793d0 100644 --- a/apps/web/src/context/dapi/nodes.ts +++ b/apps/web/src/context/dapi/nodes.ts @@ -5,7 +5,7 @@ import { AdjustmentLayer, Geometry, Group, Scene, Source } from "@diffusionstudio/runtime"; import { parseSource } from "@diffusionstudio/jsx"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; // Scenes are nodes too, so node-targeting endpoints accept them alongside // geometry, groups, and adjustment layers. diff --git a/apps/web/src/context/dapi/session.ts b/apps/web/src/context/dapi/session.ts index 55b67460..4527f42a 100644 --- a/apps/web/src/context/dapi/session.ts +++ b/apps/web/src/context/dapi/session.ts @@ -11,7 +11,7 @@ import { createSignal } from "solid-js"; import { assert } from "@/utils/common"; import type { Accessor } from "solid-js"; -import type { World } from "koota"; +import type { World } from "@diffusionstudio/koota"; /** * The open project, as the editor knows it. Structural on purpose: this is diff --git a/apps/web/src/context/export.tsx b/apps/web/src/context/export.tsx index deb0eb90..14e870db 100644 --- a/apps/web/src/context/export.tsx +++ b/apps/web/src/context/export.tsx @@ -4,7 +4,7 @@ import { createContext, useContext, onCleanup, onMount } from "solid-js"; import { toast } from "somoto"; -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { Computed, FrameRate, getActiveEntity } from "@diffusionstudio/runtime"; import { assert, downloadObject, isInputTarget } from "@/utils"; import { useEngineContext } from "@/engine"; @@ -17,7 +17,7 @@ import { getDefaultExportTemplate, } from "@/components/sidebar-right/inspector/export-templates"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { JSX, Accessor } from "solid-js"; type ExportContextValue = { diff --git a/apps/web/src/context/render.ts b/apps/web/src/context/render.ts index 17d2c94c..00201fb9 100644 --- a/apps/web/src/context/render.ts +++ b/apps/web/src/context/render.ts @@ -9,7 +9,7 @@ import { Computed, FrameRate, Workarea } from "@diffusionstudio/runtime"; import { createCapture } from "@/engine/capture"; import { version } from "../../package.json"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { EncoderConfig, ExportResult } from "@diffusionstudio/encoder"; import type { Capture } from "@/engine/capture"; import type { Engine } from "@/engine"; diff --git a/apps/web/src/context/timeline.tsx b/apps/web/src/context/timeline.tsx index f6bfac47..3b2510d4 100644 --- a/apps/web/src/context/timeline.tsx +++ b/apps/web/src/context/timeline.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createContext, useContext, type JSX } from 'solid-js'; -import { useWorld } from '@diffusionstudio/koota-solid'; +import { useWorld } from '@diffusionstudio/koota/solid'; import { assert } from '@/utils'; import { createTimelineController } from '@/engine/timeline'; diff --git a/apps/web/src/engine/align.ts b/apps/web/src/engine/align.ts index b112f565..cf7db593 100644 --- a/apps/web/src/engine/align.ts +++ b/apps/web/src/engine/align.ts @@ -25,11 +25,11 @@ import { store, transformPoint, } from "@diffusionstudio/runtime"; -import { Or } from "koota"; +import { Or } from "@diffusionstudio/koota"; import { getDocumentEditor } from "./editor"; import { syncKeyframe } from "./keyframes"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; export type AlignAction = | "align-left" diff --git a/apps/web/src/engine/asset-actions.ts b/apps/web/src/engine/asset-actions.ts index b79e238d..99d2eb4c 100644 --- a/apps/web/src/engine/asset-actions.ts +++ b/apps/web/src/engine/asset-actions.ts @@ -11,7 +11,7 @@ import { insertAsset } from "./insert-asset"; import { forgetAssetMedia } from "./timeline/media"; import { forgetAssetPeaks } from "./timeline/peaks"; -import type { World } from "koota"; +import type { World } from "@diffusionstudio/koota"; import type { Asset, AssetLibrary } from "@diffusionstudio/assets"; diff --git a/apps/web/src/engine/camera-controller.tsx b/apps/web/src/engine/camera-controller.tsx index 89a3dc74..bbcfe513 100644 --- a/apps/web/src/engine/camera-controller.tsx +++ b/apps/web/src/engine/camera-controller.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createMemo, onCleanup, onMount } from 'solid-js'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { panCamera, setCamera, zoomCameraAt, getCamera, RenderSurface, getCameraMatrix, Root, Tool, ToolType } from '@diffusionstudio/runtime'; import { useEditor } from './hooks/use-editor'; diff --git a/apps/web/src/engine/camera.ts b/apps/web/src/engine/camera.ts index 75e23415..13e4128e 100644 --- a/apps/web/src/engine/camera.ts +++ b/apps/web/src/engine/camera.ts @@ -20,7 +20,7 @@ import { import { getDocumentEditor } from './editor'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; function reportCamera(world: World): void { getDocumentEditor(world).reportEdit(world.get(Root)!, 'camera', getCameraMatrix(world)); diff --git a/apps/web/src/engine/capture.ts b/apps/web/src/engine/capture.ts index 52ef6180..1b52fc0c 100644 --- a/apps/web/src/engine/capture.ts +++ b/apps/web/src/engine/capture.ts @@ -31,7 +31,7 @@ import { loadProjectBundle } from '@/lib/db'; import { compileProject, isDesktop } from '@/projects'; import type { RuntimeMode } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export interface CaptureOptions { /** Frames per second the copy is timed at; the source world's by default. */ diff --git a/apps/web/src/engine/context.tsx b/apps/web/src/engine/context.tsx index 47d1934a..d908dd70 100644 --- a/apps/web/src/engine/context.tsx +++ b/apps/web/src/engine/context.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createContext, onCleanup, useContext, type JSX } from 'solid-js'; -import { WorldProvider } from '@diffusionstudio/koota-solid'; +import { WorldProvider } from '@diffusionstudio/koota/solid'; import { createEngine, type Engine, type EngineOptions } from './create-engine'; @@ -17,7 +17,7 @@ export interface EngineProviderProps { /** * Provides the Engine instance (lifecycle: mount/resize/start/stop) and, - * via koota-solid's WorldProvider, its koota world. Deliberately does not + * via @diffusionstudio/koota/solid's WorldProvider, its koota world. Deliberately does not * hand out a bag of engine internals — components read world-trait state * with useTrait/useQuery (see ./hooks) and reach the Engine itself only for * lifecycle calls (useEngineContext()), keeping each consumer's dependency diff --git a/apps/web/src/engine/editor.ts b/apps/web/src/engine/editor.ts index d06c07cd..b4f2bba0 100644 --- a/apps/web/src/engine/editor.ts +++ b/apps/web/src/engine/editor.ts @@ -18,7 +18,7 @@ import { authoredElement, authoredTree, getRuntimeDocument, insert, isSceneNode, import type { SceneNode } from '@diffusionstudio/runtime'; import type { PropValue, SerializedAssetRef } from '@diffusionstudio/jsx'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { AuthoredTree, ProjectDocument, RuntimeDocument } from '@diffusionstudio/reconciler'; /** diff --git a/apps/web/src/engine/group.tsx b/apps/web/src/engine/group.tsx index f01d1c6b..672bacb6 100644 --- a/apps/web/src/engine/group.tsx +++ b/apps/web/src/engine/group.tsx @@ -31,7 +31,7 @@ import { store, translate2D, } from '@diffusionstudio/runtime'; -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { getDocumentEditor } from './editor'; import { editTransform } from './input/interactions'; @@ -42,7 +42,7 @@ import { authoredTime } from './timing'; import type { DocumentEditor } from './editor'; import type { TransformWrite } from './input/interactions'; import type { Mat2D } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** The node kinds a group holds; a mask belongs to its target, not the group. */ const NODES = Or(Geometry, Group, AdjustmentLayer); diff --git a/apps/web/src/engine/history.ts b/apps/web/src/engine/history.ts index 532f7ae0..8c81b834 100644 --- a/apps/web/src/engine/history.ts +++ b/apps/web/src/engine/history.ts @@ -30,7 +30,7 @@ import { getDocumentEditor } from './editor'; import type { AuthoredTree } from '@diffusionstudio/reconciler'; import type { PropValue } from '@diffusionstudio/jsx'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { CapturedNode, DocumentEditor, EntityEdit } from './editor'; /** diff --git a/apps/web/src/engine/hooks/use-active-scene.ts b/apps/web/src/engine/hooks/use-active-scene.ts index dd497f1f..0b3fac91 100644 --- a/apps/web/src/engine/hooks/use-active-scene.ts +++ b/apps/web/src/engine/hooks/use-active-scene.ts @@ -3,9 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Active } from '@diffusionstudio/runtime'; -import { useQuery } from '@diffusionstudio/koota-solid'; +import { useQuery } from '@diffusionstudio/koota/solid'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; import type { Accessor } from 'solid-js'; /** Reactive read of the entity carrying Active. Write via `useEditor().activate(...)`. */ diff --git a/apps/web/src/engine/hooks/use-asset-selection.ts b/apps/web/src/engine/hooks/use-asset-selection.ts index 6f1bee81..017f25f1 100644 --- a/apps/web/src/engine/hooks/use-asset-selection.ts +++ b/apps/web/src/engine/hooks/use-asset-selection.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { createMemo } from 'solid-js'; import { AssetSelection } from '../traits'; diff --git a/apps/web/src/engine/hooks/use-camera.ts b/apps/web/src/engine/hooks/use-camera.ts deleted file mode 100644 index 50fd9e31..00000000 --- a/apps/web/src/engine/hooks/use-camera.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { Camera, Root, type Camera2D } from '@diffusionstudio/runtime'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; - -import type { Accessor } from 'solid-js'; - -/** - * Reactive read of the document's Camera trait. Write it with the operations - * in ../camera (they take the world), never by mutating the record. - */ -export function useCamera(): Accessor { - const world = useWorld(); - return useTrait(world.get(Root)!, Camera); -} - -/** Reactive zoom factor of the stage camera (1 = 100%). */ -export function useCameraScale(): Accessor { - const camera = useCamera(); - return () => { - const { a = 1, b = 0 } = camera() ?? {}; - return Math.hypot(a, b); - }; -} diff --git a/apps/web/src/engine/hooks/use-document.ts b/apps/web/src/engine/hooks/use-document.ts index 13a6e0c2..50ab027b 100644 --- a/apps/web/src/engine/hooks/use-document.ts +++ b/apps/web/src/engine/hooks/use-document.ts @@ -1,4 +1,4 @@ -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { getRuntimeDocument } from "@diffusionstudio/reconciler"; export function useDocument() { diff --git a/apps/web/src/engine/hooks/use-editor.ts b/apps/web/src/engine/hooks/use-editor.ts index 71176c9f..a3cca609 100644 --- a/apps/web/src/engine/hooks/use-editor.ts +++ b/apps/web/src/engine/hooks/use-editor.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useWorld } from "@diffusionstudio/koota-solid"; +import { useWorld } from "@diffusionstudio/koota/solid"; import { getDocumentEditor } from "../editor"; import type { DocumentEditor } from "../editor"; diff --git a/apps/web/src/engine/hooks/use-selection.ts b/apps/web/src/engine/hooks/use-selection.ts index ce073aed..acd4e806 100644 --- a/apps/web/src/engine/hooks/use-selection.ts +++ b/apps/web/src/engine/hooks/use-selection.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { AdjustmentLayer, Geometry, Group, Keyframe, Selected } from '@diffusionstudio/runtime'; -import { useQuery } from '@diffusionstudio/koota-solid'; +import { useQuery } from '@diffusionstudio/koota/solid'; import { createMemo } from 'solid-js'; /** diff --git a/apps/web/src/engine/hooks/use-timeline-index.ts b/apps/web/src/engine/hooks/use-timeline-index.ts index cd5f9188..5e1619c2 100644 --- a/apps/web/src/engine/hooks/use-timeline-index.ts +++ b/apps/web/src/engine/hooks/use-timeline-index.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { buildTimelineLayers, getActiveEntity } from '@diffusionstudio/runtime'; -import { useWorld } from '@diffusionstudio/koota-solid'; +import { useWorld } from '@diffusionstudio/koota/solid'; import { useDerived } from './use-derived'; diff --git a/apps/web/src/engine/hooks/use-tool.ts b/apps/web/src/engine/hooks/use-tool.ts index 3e6cca1f..45f6cebe 100644 --- a/apps/web/src/engine/hooks/use-tool.ts +++ b/apps/web/src/engine/hooks/use-tool.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Tool, ToolType } from '@diffusionstudio/runtime'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import type { Accessor } from 'solid-js'; diff --git a/apps/web/src/engine/hud/hud-system.ts b/apps/web/src/engine/hud/hud-system.ts index aafefc97..560070d9 100644 --- a/apps/web/src/engine/hud/hud-system.ts +++ b/apps/web/src/engine/hud/hud-system.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { Active, Computed, Culled, FrameRate, Geometry, Group, Hidden, HitRegions, Hovering, Name, Playback, RenderSurface, Root, Selected, @@ -21,7 +21,7 @@ import { import { getMarqueeQuad } from '../input/snapping'; import { getMountedNameInput } from './name-input'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Mat2D } from '@diffusionstudio/runtime'; const ACCENT = '#008CFF'; diff --git a/apps/web/src/engine/hud/name-input.ts b/apps/web/src/engine/hud/name-input.ts index a414fc40..f5ddc9e7 100644 --- a/apps/web/src/engine/hud/name-input.ts +++ b/apps/web/src/engine/hud/name-input.ts @@ -12,7 +12,7 @@ import { Name, RenderSurface } from '@diffusionstudio/runtime'; import { getDocumentEditor } from '../editor'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; const INPUT_STYLE = { position: 'absolute', diff --git a/apps/web/src/engine/input/cursor.ts b/apps/web/src/engine/input/cursor.ts index c7aae79e..5c7d116e 100644 --- a/apps/web/src/engine/input/cursor.ts +++ b/apps/web/src/engine/input/cursor.ts @@ -15,7 +15,7 @@ import { RenderSurface, Tool, ToolType } from '@diffusionstudio/runtime'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; const CORNER_SVG = ``; const EDGE_SVG = ``; diff --git a/apps/web/src/engine/input/input-system.ts b/apps/web/src/engine/input/input-system.ts index 92428f39..082c1e59 100644 --- a/apps/web/src/engine/input/input-system.ts +++ b/apps/web/src/engine/input/input-system.ts @@ -9,7 +9,7 @@ import { Keys, Pointer, PointerEvents } from '../traits'; import { getToolCursor, updateCursor } from './cursor'; import { handleCanvasInteraction, handleGeometryInteraction } from './interactions'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { CursorType } from './cursor'; import type { CanvasPointerEvent, DispatchedPointerEvent, HitRegion, PointerEventType } from '@diffusionstudio/runtime'; diff --git a/apps/web/src/engine/input/interactions.ts b/apps/web/src/engine/input/interactions.ts index 4cb4ccb0..82c704c5 100644 --- a/apps/web/src/engine/input/interactions.ts +++ b/apps/web/src/engine/input/interactions.ts @@ -28,7 +28,7 @@ import { rotate2D, scale2D, store, syncInteractiveState, togglePlayback, transformPoint, translate2D, } from '@diffusionstudio/runtime'; -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { getDocumentEditor } from '../editor'; import { syncKeyframe } from '../keyframes'; @@ -43,7 +43,7 @@ import { } from './snapping'; import type { DocumentEditor } from '../editor'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { DispatchedPointerEvent, Mat2D, Point, Quad } from '@diffusionstudio/runtime'; /** How close, in CSS pixels, a candidate has to be before a gesture snaps to it. */ diff --git a/apps/web/src/engine/input/shortcuts.ts b/apps/web/src/engine/input/shortcuts.ts index eb8da1c9..1e46a0ee 100644 --- a/apps/web/src/engine/input/shortcuts.ts +++ b/apps/web/src/engine/input/shortcuts.ts @@ -28,7 +28,7 @@ import { store, togglePlayback, } from '@diffusionstudio/runtime'; -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { zoomBy, zoomTo, zoomToFit, zoomToSelection } from '../camera'; import { getDocumentEditor } from '../editor'; @@ -40,7 +40,7 @@ import { editTransform } from './interactions'; import type { TransformWrite } from './interactions'; import type { CameraMatrix } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; type Shortcut = { keys: string[]; diff --git a/apps/web/src/engine/input/snapping.ts b/apps/web/src/engine/input/snapping.ts index d473bf78..153bf0f1 100644 --- a/apps/web/src/engine/input/snapping.ts +++ b/apps/web/src/engine/input/snapping.ts @@ -12,7 +12,7 @@ * pointer's own is. */ -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { ChildOf, Computed, Culled, Geometry, Group, Hidden, Root, Scale, entityLocalMat, entityQuad, entityWorldMat, getParentEntity, getSelection, @@ -21,7 +21,7 @@ import { import { Hud, Pointer } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Mat2D, Point, Quad, SelectionMask } from '@diffusionstudio/runtime'; export type NodeSnapshot = { diff --git a/apps/web/src/engine/insert-asset.tsx b/apps/web/src/engine/insert-asset.tsx index 34a39e6f..0e0eadf1 100644 --- a/apps/web/src/engine/insert-asset.tsx +++ b/apps/web/src/engine/insert-asset.tsx @@ -10,7 +10,7 @@ import { assetName } from '@diffusionstudio/assets'; import { getDocumentEditor } from './editor'; import type { Asset } from '@diffusionstudio/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export interface InsertAssetOptions { /** The scene (or group) to insert into; the active scene by default. */ diff --git a/apps/web/src/engine/keyframes.tsx b/apps/web/src/engine/keyframes.tsx index 8040ef99..962ac069 100644 --- a/apps/web/src/engine/keyframes.tsx +++ b/apps/web/src/engine/keyframes.tsx @@ -28,7 +28,7 @@ import { } from "@diffusionstudio/runtime"; import type { AnimatableProperty } from "@diffusionstudio/jsx"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; import type { DocumentEditor } from "./editor"; /** The track driving `target`'s `property`, if any. */ diff --git a/apps/web/src/engine/library.ts b/apps/web/src/engine/library.ts index 58139f16..5d878b19 100644 --- a/apps/web/src/engine/library.ts +++ b/apps/web/src/engine/library.ts @@ -8,7 +8,7 @@ import { AssetId, Library } from '@diffusionstudio/runtime'; import { AssetLibrary, MANIFEST_FILE, ASSETS_DIR } from '@diffusionstudio/assets'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { authoredElement } from '@diffusionstudio/reconciler'; import type { Accessor } from 'solid-js'; @@ -16,7 +16,7 @@ import { createProjectFS } from '@/projects/fs'; import { getDocumentEditor } from './editor'; import type { Asset } from '@diffusionstudio/assets'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; /** * Creates the library of the project at `dir`, attaches it to the world and diff --git a/apps/web/src/engine/new-scene.tsx b/apps/web/src/engine/new-scene.tsx index 8f64c5d9..5f57d4d7 100644 --- a/apps/web/src/engine/new-scene.tsx +++ b/apps/web/src/engine/new-scene.tsx @@ -16,7 +16,7 @@ import { AUDIO_SIZE, insertAsset } from './insert-asset'; import type { Asset } from '@diffusionstudio/assets'; import type { Rect } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export interface Size { width: number; diff --git a/apps/web/src/engine/overlap.ts b/apps/web/src/engine/overlap.ts index 114bd3eb..208520e1 100644 --- a/apps/web/src/engine/overlap.ts +++ b/apps/web/src/engine/overlap.ts @@ -16,13 +16,13 @@ */ import { ChildOf, Computed, Geometry, Group, Sequential, getParentNode, isGroup, store } from '@diffusionstudio/runtime'; -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { getDocumentEditor } from './editor'; import { trimIn, trimOut } from './timing'; import type { DocumentEditor } from './editor'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Settles every sequence the just-dropped clips landed in. `dragged` is what diff --git a/apps/web/src/engine/placement.ts b/apps/web/src/engine/placement.ts index 3ab7d8fd..2841f77a 100644 --- a/apps/web/src/engine/placement.ts +++ b/apps/web/src/engine/placement.ts @@ -11,7 +11,7 @@ import { } from '@diffusionstudio/runtime'; import type { AABB, Point } from '@diffusionstudio/runtime'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; /** The visible part of the canvas in document space, or null before a surface is mounted. */ function viewportBounds(world: World): AABB | null { diff --git a/apps/web/src/engine/project-config.ts b/apps/web/src/engine/project-config.ts index d6128172..0a2589cc 100644 --- a/apps/web/src/engine/project-config.ts +++ b/apps/web/src/engine/project-config.ts @@ -22,13 +22,13 @@ import { createSignal } from 'solid-js'; import { Source } from '@diffusionstudio/runtime'; import { parseSource } from '@diffusionstudio/jsx'; -import { useTrait, useWorld } from '@diffusionstudio/koota-solid'; +import { useTrait, useWorld } from '@diffusionstudio/koota/solid'; import { readProjectConfig, writeProjectConfig } from '@/projects/host'; import { ProjectConfig as ProjectConfigTrait } from './traits'; import type { VideoCodec, AudioCodec } from 'mediabunny'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Accessor } from 'solid-js'; /** The file the config lives in, relative to the project. */ diff --git a/apps/web/src/engine/source-errors.ts b/apps/web/src/engine/source-errors.ts index 75b13a08..cd44899b 100644 --- a/apps/web/src/engine/source-errors.ts +++ b/apps/web/src/engine/source-errors.ts @@ -24,7 +24,7 @@ import { authoredElement } from '@diffusionstudio/reconciler'; import { getDocumentEditor, isLooped } from './editor'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; export function sourceErrorSystem(world: World): void { for (const entity of world.query(SourceError)) { diff --git a/apps/web/src/engine/split.tsx b/apps/web/src/engine/split.tsx index dee88f1f..a38f46ac 100644 --- a/apps/web/src/engine/split.tsx +++ b/apps/web/src/engine/split.tsx @@ -26,13 +26,13 @@ import { isSequence, store, } from '@diffusionstudio/runtime'; -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { getDocumentEditor } from './editor'; import { cloneFramesForSplit, clonePeaksForSplit } from './timeline'; import { trimIn, trimOut } from './timing'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** The node kinds that sit on a timeline, and so are the ones a cut is about. */ const NODES = Or(Geometry, Group, AdjustmentLayer); diff --git a/apps/web/src/engine/timeline/controller.ts b/apps/web/src/engine/timeline/controller.ts index c6e855e6..5e68db39 100644 --- a/apps/web/src/engine/timeline/controller.ts +++ b/apps/web/src/engine/timeline/controller.ts @@ -34,7 +34,7 @@ import { } from './config'; import { getFrameRate } from './view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export type TimelineController = ReturnType; diff --git a/apps/web/src/engine/timeline/drag.ts b/apps/web/src/engine/timeline/drag.ts index e9d2fe2d..3ee8ff53 100644 --- a/apps/web/src/engine/timeline/drag.ts +++ b/apps/web/src/engine/timeline/drag.ts @@ -24,7 +24,7 @@ import { findAssetDuration, store, } from '@diffusionstudio/runtime'; -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { clamp } from '@/utils'; import { resolveSequentialOverlaps } from '../overlap'; @@ -32,7 +32,7 @@ import { authoredTime, moveEntityTo, trimIn, trimOut } from '../timing'; import { findSnapDelta, findSnapFrame } from './snapping'; import { framesToPixels, pixelsToFrames } from './view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from './surface'; const NODES = Or(Geometry, Group, AdjustmentLayer); diff --git a/apps/web/src/engine/timeline/layout.ts b/apps/web/src/engine/timeline/layout.ts index 75c49330..dd4a62da 100644 --- a/apps/web/src/engine/timeline/layout.ts +++ b/apps/web/src/engine/timeline/layout.ts @@ -14,7 +14,7 @@ import { ClipHeight, Timeline, store } from '@diffusionstudio/runtime'; import { DEFAULT_CLIP_HEIGHT, KEYFRAME_TRACK_HEIGHT } from './config'; import type { TimelineNode } from '@diffusionstudio/runtime'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Where the walk has got to and how tall the row it is in is. One object diff --git a/apps/web/src/engine/timeline/peaks.ts b/apps/web/src/engine/timeline/peaks.ts index 5980bac1..14f666f2 100644 --- a/apps/web/src/engine/timeline/peaks.ts +++ b/apps/web/src/engine/timeline/peaks.ts @@ -5,7 +5,7 @@ import { Library } from '@diffusionstudio/runtime'; import { WAVEFORM_PEAKS_PER_SECOND, downsamplePeaks } from '@diffusionstudio/assets'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { AssetCache, AudioAsset, VideoAsset } from '@diffusionstudio/assets'; /** How wide one drawn sample is, in pixels. */ diff --git a/apps/web/src/engine/timeline/render/caption.ts b/apps/web/src/engine/timeline/render/caption.ts index 76673b8d..3f0cff13 100644 --- a/apps/web/src/engine/timeline/render/caption.ts +++ b/apps/web/src/engine/timeline/render/caption.ts @@ -9,7 +9,7 @@ import { getClipStyle } from '../style'; import { truncateText } from '../text'; import { framesToPixels, getFrameRate, getResolution } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/clip.ts b/apps/web/src/engine/timeline/render/clip.ts index 50f5b871..5ae50bf7 100644 --- a/apps/web/src/engine/timeline/render/clip.ts +++ b/apps/web/src/engine/timeline/render/clip.ts @@ -23,7 +23,7 @@ import { renderGroup } from './group'; import { renderStillThumbnails, renderVideoThumbnails } from './thumbnails'; import { renderWaveform } from './waveform'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { ClipStyle } from '../style'; import type { RowCursor } from '../layout'; diff --git a/apps/web/src/engine/timeline/render/group.ts b/apps/web/src/engine/timeline/render/group.ts index 9925e86f..6cba6ef6 100644 --- a/apps/web/src/engine/timeline/render/group.ts +++ b/apps/web/src/engine/timeline/render/group.ts @@ -7,7 +7,7 @@ import { Cache, Computed, store } from '@diffusionstudio/runtime'; import { CLIP_CORNER_RADIUS } from '../config'; import { framesToPixels, getResolution } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/keyframes.ts b/apps/web/src/engine/timeline/render/keyframes.ts index 03449660..df79c993 100644 --- a/apps/web/src/engine/timeline/render/keyframes.ts +++ b/apps/web/src/engine/timeline/render/keyframes.ts @@ -19,7 +19,7 @@ import { KEYFRAME_TRACK_HEIGHT } from '../config'; import { getRowTransform } from '../layout'; import { framesToPixels, getFrameRate, getResolution, getViewport, pixelsToFrames } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/layers.ts b/apps/web/src/engine/timeline/render/layers.ts index a6f3cfe1..0a15903a 100644 --- a/apps/web/src/engine/timeline/render/layers.ts +++ b/apps/web/src/engine/timeline/render/layers.ts @@ -13,7 +13,7 @@ import { buildTimelineLayers, store, } from '@diffusionstudio/runtime'; -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { getNodeHeight, getRowTransform, getSubtreeHeight } from '../layout'; import { getResolution, getViewport, pixelsToFrames } from '../view'; @@ -21,7 +21,7 @@ import { isDragging } from '../drag'; import { getClipAlpha, renderClip } from './clip'; import { renderKeyframeTrack } from './keyframes'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineNode } from '@diffusionstudio/runtime'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/marquee.ts b/apps/web/src/engine/timeline/render/marquee.ts index 1c4559ff..f895fc32 100644 --- a/apps/web/src/engine/timeline/render/marquee.ts +++ b/apps/web/src/engine/timeline/render/marquee.ts @@ -4,7 +4,7 @@ import { getDocumentEditor } from '../../editor'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from '../surface'; /** diff --git a/apps/web/src/engine/timeline/render/playhead.ts b/apps/web/src/engine/timeline/render/playhead.ts index a6643a79..63dc63b7 100644 --- a/apps/web/src/engine/timeline/render/playhead.ts +++ b/apps/web/src/engine/timeline/render/playhead.ts @@ -6,7 +6,7 @@ import { KNOB_PATH } from '../paths'; import { RULER_HEIGHT } from '../config'; import { framesToPixels, getCurrentFrame, getFrameRate, getResolution, getScrollX } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from '../surface'; // The knob shown in place of the line while the timeline is collapsed. diff --git a/apps/web/src/engine/timeline/render/ruler.ts b/apps/web/src/engine/timeline/render/ruler.ts index 129d6828..cb656209 100644 --- a/apps/web/src/engine/timeline/render/ruler.ts +++ b/apps/web/src/engine/timeline/render/ruler.ts @@ -16,7 +16,7 @@ import { import { editWorkarea } from '../../timing'; import { framesToPixels, getFrameRate, getResolution, getScrollX, getViewport, pixelsToFrames } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from '../surface'; /** diff --git a/apps/web/src/engine/timeline/render/snap.ts b/apps/web/src/engine/timeline/render/snap.ts index 78adea24..68d86a62 100644 --- a/apps/web/src/engine/timeline/render/snap.ts +++ b/apps/web/src/engine/timeline/render/snap.ts @@ -5,7 +5,7 @@ import { RULER_HEIGHT } from '../config'; import { getResolution, getScrollX } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from '../surface'; /** diff --git a/apps/web/src/engine/timeline/render/thumbnails.ts b/apps/web/src/engine/timeline/render/thumbnails.ts index 6f3cf52a..d4d7eb5a 100644 --- a/apps/web/src/engine/timeline/render/thumbnails.ts +++ b/apps/web/src/engine/timeline/render/thumbnails.ts @@ -10,7 +10,7 @@ import { framesToPixels, getFrameRate, getResolution, getViewport, pixelsToFrame import { renderWaveform } from './waveform'; import type { Asset, VideoAsset } from '@diffusionstudio/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/waveform.ts b/apps/web/src/engine/timeline/render/waveform.ts index 725bbc9b..f09a5784 100644 --- a/apps/web/src/engine/timeline/render/waveform.ts +++ b/apps/web/src/engine/timeline/render/waveform.ts @@ -9,7 +9,7 @@ import { SAMPLE_WIDTH, getClipSamples, requestPeaks } from '../peaks'; import { framesToPixels, getFrameRate, getResolution, getViewport } from '../view'; import type { AudioAsset, VideoAsset } from '@diffusionstudio/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { RowCursor } from '../layout'; import type { TimelineSurfaceState } from '../surface'; diff --git a/apps/web/src/engine/timeline/render/workarea.ts b/apps/web/src/engine/timeline/render/workarea.ts index ff135798..7606a1ff 100644 --- a/apps/web/src/engine/timeline/render/workarea.ts +++ b/apps/web/src/engine/timeline/render/workarea.ts @@ -10,7 +10,7 @@ import { IN_POINT_PATH, OUT_POINT_PATH } from '../paths'; import { RULER_HEIGHT } from '../config'; import { framesToPixels, getResolution, getScrollX, pixelsToFrames } from '../view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { TimelineSurfaceState } from '../surface'; const HANDLE_WIDTH = 5; diff --git a/apps/web/src/engine/timeline/snapping.ts b/apps/web/src/engine/timeline/snapping.ts index 52d3fb90..fc4cdf4c 100644 --- a/apps/web/src/engine/timeline/snapping.ts +++ b/apps/web/src/engine/timeline/snapping.ts @@ -23,12 +23,12 @@ import { getParentEntity, store, } from '@diffusionstudio/runtime'; -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { SNAP_DISTANCE } from './config'; import { framesToPixels, getCurrentFrame } from './view'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; const NODES = Or(Geometry, Group, AdjustmentLayer); diff --git a/apps/web/src/engine/timeline/style.ts b/apps/web/src/engine/timeline/style.ts index d50cd57c..3b3c5285 100644 --- a/apps/web/src/engine/timeline/style.ts +++ b/apps/web/src/engine/timeline/style.ts @@ -20,7 +20,7 @@ import { import { COLORS } from './constants'; import type { Asset } from '@diffusionstudio/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export type ClipStyle = { background: string; diff --git a/apps/web/src/engine/timeline/surface.ts b/apps/web/src/engine/timeline/surface.ts index 0f8f7f51..372bb08a 100644 --- a/apps/web/src/engine/timeline/surface.ts +++ b/apps/web/src/engine/timeline/surface.ts @@ -20,7 +20,7 @@ * be) and none of it is worth an event. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { COLORS } from './constants'; diff --git a/apps/web/src/engine/timeline/timeline.ts b/apps/web/src/engine/timeline/timeline.ts index 51b87dfc..c1581947 100644 --- a/apps/web/src/engine/timeline/timeline.ts +++ b/apps/web/src/engine/timeline/timeline.ts @@ -16,7 +16,7 @@ import { updateDragGestures } from './drag'; import { TimelineSurface } from './surface'; import { ensureTimelineView, getTimelineScene } from './view'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { TimelinePointer } from './pointer'; import type { TimelineSurfaceState } from './surface'; diff --git a/apps/web/src/engine/timeline/view.ts b/apps/web/src/engine/timeline/view.ts index 89cf6740..689eb232 100644 --- a/apps/web/src/engine/timeline/view.ts +++ b/apps/web/src/engine/timeline/view.ts @@ -14,7 +14,7 @@ import { Computed, FrameRate, Timeline, getActiveEntity, store } from '@diffusio import { DEFAULT_TIMELINE_RESOLUTION, RULER_HEIGHT, TIMELINE_PADDING_LEFT } from './config'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** The scene the timeline shows: the one holding the playhead. */ export function getTimelineScene(world: World): Entity | null { diff --git a/apps/web/src/engine/timing.ts b/apps/web/src/engine/timing.ts index 80d68789..cce9c0b1 100644 --- a/apps/web/src/engine/timing.ts +++ b/apps/web/src/engine/timing.ts @@ -30,7 +30,7 @@ import { parseTime } from '@diffusionstudio/jsx'; import { getDocumentEditor } from './editor'; import type { Time } from '@diffusionstudio/jsx'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** The time a node authors, in the vocabulary of the JSX rather than the traits'. */ export type TimeProp = 'start' | 'end' | 'sourceIn' | 'sourceOut'; diff --git a/apps/web/src/engine/traits.ts b/apps/web/src/engine/traits.ts index fc7bcef8..dcaa37ad 100644 --- a/apps/web/src/engine/traits.ts +++ b/apps/web/src/engine/traits.ts @@ -9,7 +9,7 @@ * pointer), and none of it is authored, so it never reaches the source. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import type { CanvasPointerEvent, Point } from '@diffusionstudio/runtime'; import type { ProjectConfig as ProjectConfigStore } from './project-config'; diff --git a/apps/web/src/pages/editor.tsx b/apps/web/src/pages/editor.tsx index cd296084..6d27d160 100644 --- a/apps/web/src/pages/editor.tsx +++ b/apps/web/src/pages/editor.tsx @@ -12,7 +12,7 @@ import { useEditorApi } from "@/context/dapi"; import { RULER_HEIGHT } from "@/engine/timeline"; import { createEffect, onCleanup, untrack } from 'solid-js'; import { toast } from 'somoto'; -import { useWorld } from '@diffusionstudio/koota-solid'; +import { useWorld } from '@diffusionstudio/koota/solid'; import { mount } from '@diffusionstudio/reconciler'; import { getDocumentEditor } from '@/engine/editor'; import { getEditHistory } from '@/engine/history'; diff --git a/apps/web/src/projects/edits.ts b/apps/web/src/projects/edits.ts index 49c7a28f..93a409cb 100644 --- a/apps/web/src/projects/edits.ts +++ b/apps/web/src/projects/edits.ts @@ -10,7 +10,7 @@ import { writeProject } from './host'; import type { EntityEdit, InsertEdit, MoveEdit, RemoveEdit, UnrollEdit } from '@/engine/editor'; import type { SourceEdit, WriteResult } from './host'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; /** * How long edits pile up before they are written. Long enough that a drag is diff --git a/apps/web/src/utils/gen-ai.ts b/apps/web/src/utils/gen-ai.ts index 2b8df4f1..acadf8ff 100644 --- a/apps/web/src/utils/gen-ai.ts +++ b/apps/web/src/utils/gen-ai.ts @@ -28,7 +28,7 @@ import type { AspectRatio, AssetInput, AssetRef, AssetSpecInput } from "@diffusi import type { Asset, AssetLibrary, AssetType } from "@diffusionstudio/assets"; import type { FileRef } from "@diffusionstudio/api-contract"; import type { ExportResult } from "@diffusionstudio/encoder"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; /** What a failure is called where the user reads about it. */ const FAILURE_TITLES: Record = { diff --git a/package-lock.json b/package-lock.json index 5e3f8ee1..0788db9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,7 @@ "@diffusionstudio/cli": "*", "@diffusionstudio/encoder": "*", "@diffusionstudio/jsx": "*", - "@diffusionstudio/koota-solid": "*", + "@diffusionstudio/koota": "*", "@diffusionstudio/reconciler": "*", "@diffusionstudio/runtime": "*", "@kobalte/core": "^0.13.11", @@ -113,7 +113,6 @@ "colord": "^2.9.3", "cva": "^1.0.0-beta.4", "idb": "^8.0.3", - "koota": "^0.6.6", "mediabunny": "^1.32.2", "solid-js": "^1.9.10", "somoto": "^0.0.2", @@ -635,8 +634,8 @@ "resolved": "packages/jsx", "link": true }, - "node_modules/@diffusionstudio/koota-solid": { - "resolved": "packages/koota-solid", + "node_modules/@diffusionstudio/koota": { + "resolved": "packages/koota", "link": true }, "node_modules/@diffusionstudio/reconciler": { @@ -8265,24 +8264,6 @@ "dev": true, "license": "MIT" }, - "node_modules/koota": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/koota/-/koota-0.6.6.tgz", - "integrity": "sha512-Fn8lSooH3u9TIP+eAMugFrOju40Ec70+sB7x1eZBB3H0Lki2hyIbFAodk3NDB/RIRzoMs54O4422BVN6SbtWfw==", - "license": "ISC", - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12412,8 +12393,8 @@ "version": "0.1.0", "license": "MPL-2.0", "dependencies": { + "@diffusionstudio/koota": "*", "@diffusionstudio/runtime": "*", - "koota": "^0.6.6", "mediabunny": "^1.32.2" }, "devDependencies": { @@ -12424,10 +12405,10 @@ }, "packages/jsx": { "name": "@diffusionstudio/jsx", - "version": "0.1.0", + "version": "0.200.0", "license": "MPL-2.0", "dependencies": { - "koota": "^0.6.6" + "@diffusionstudio/koota": "*" }, "devDependencies": { "typescript": "~5.9.3" @@ -12436,12 +12417,29 @@ "solid-js": "^1.9.0" } }, + "packages/koota": { + "name": "@diffusionstudio/koota", + "version": "0.6.6", + "license": "ISC AND MPL-2.0", + "devDependencies": { + "typescript": "~5.9.3" + }, + "peerDependencies": { + "solid-js": "^1.9.0" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, "packages/koota-solid": { "name": "@diffusionstudio/koota-solid", "version": "0.1.0", + "extraneous": true, "license": "MPL-2.0", "dependencies": { - "koota": "^0.6.6" + "@diffusionstudio/koota": "*" }, "devDependencies": { "typescript": "~5.9.3" @@ -12457,8 +12455,8 @@ "dependencies": { "@diffusionstudio/assets": "*", "@diffusionstudio/jsx": "*", - "@diffusionstudio/runtime": "*", - "koota": "^0.6.6" + "@diffusionstudio/koota": "*", + "@diffusionstudio/runtime": "*" }, "devDependencies": { "@webgpu/types": "^0.1.71", @@ -12475,9 +12473,9 @@ "dependencies": { "@diffusionstudio/assets": "*", "@diffusionstudio/jsx": "*", + "@diffusionstudio/koota": "*", "animejs": "^4.4.1", "colord": "^2.9.3", - "koota": "^0.6.6", "mediabunny": "^1.32.2" }, "devDependencies": { diff --git a/packages/encoder/package.json b/packages/encoder/package.json index e8b797fa..a3431144 100644 --- a/packages/encoder/package.json +++ b/packages/encoder/package.json @@ -18,7 +18,7 @@ ], "dependencies": { "@diffusionstudio/runtime": "*", - "koota": "^0.6.6", + "@diffusionstudio/koota": "*", "mediabunny": "^1.32.2" }, "devDependencies": { diff --git a/packages/encoder/src/encoder.ts b/packages/encoder/src/encoder.ts index 61d75fda..3c74ca32 100644 --- a/packages/encoder/src/encoder.ts +++ b/packages/encoder/src/encoder.ts @@ -8,7 +8,7 @@ import { AudioSample, AudioSampleSource, } from 'mediabunny'; -import { Not } from 'koota'; +import { Not } from '@diffusionstudio/koota'; import { setActive, framesToSeconds, assert, store, isScene, @@ -25,7 +25,7 @@ import { TargetBuffer } from './buffer'; import { createOutputFormat } from './format'; import { createRenderEventDetail } from './utils'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { EncoderConfig } from './interfaces'; import type { ExportResult } from './types'; diff --git a/packages/encoder/src/image-encoder.ts b/packages/encoder/src/image-encoder.ts index 85441422..5d8ea0c4 100644 --- a/packages/encoder/src/image-encoder.ts +++ b/packages/encoder/src/image-encoder.ts @@ -11,7 +11,7 @@ import { import { captureScene, normalizeSceneTransform, resolverSystem, warmupAssets } from './encoder'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type { ImageEncoderConfig } from './interfaces'; /** One capture: the PNG plus the timecode of the frame rendered, e.g. `01s15f`. */ diff --git a/packages/jsx/package.json b/packages/jsx/package.json index 63749e02..97552b1b 100644 --- a/packages/jsx/package.json +++ b/packages/jsx/package.json @@ -31,7 +31,7 @@ "video-editing" ], "dependencies": { - "koota": "^0.6.6" + "@diffusionstudio/koota": "*" }, "peerDependencies": { "solid-js": "^1.9.0" diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts index 9cff2dfd..c542a240 100644 --- a/packages/jsx/src/types.ts +++ b/packages/jsx/src/types.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { JSX as SolidJSX } from "solid-js"; -import type { Entity } from "koota"; +import type { Entity } from "@diffusionstudio/koota"; import type { AssetRef } from "./generate"; /** diff --git a/packages/koota-solid/README.md b/packages/koota-solid/README.md deleted file mode 100644 index ac22a504..00000000 --- a/packages/koota-solid/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# @diffusionstudio/koota-solid - -Solid bindings for [koota](https://github.com/pmndrs/koota): a port of `@koota/react`. The API mirrors `@koota/react` one to one, adapted to Solid's reactivity model. - -## Usage - -```tsx -import { createWorld, trait } from 'koota'; -import { WorldProvider, useQuery, useTrait } from '@diffusionstudio/koota-solid'; - -const Position = trait({ x: 0, y: 0 }); -const world = createWorld(); - -function App() { - return ( - - - - ); -} - -function EntityList() { - const entities = useQuery(Position); - return {(entity) => }; -} - -function EntityView(props: { entity: Entity }) { - // Pass reactive inputs as accessors so the hook resubscribes when they change - const pos = useTrait(() => props.entity, Position); - return
{pos()?.x}, {pos()?.y}
; -} -``` - -## Differences from `@koota/react` - -- Every hook returns an `Accessor` instead of a plain value: call it (`pos()`, `entities()`) inside JSX, memos, or effects to subscribe. -- Solid components run once, so hooks are set up once. Where React re-renders with new props, here you pass reactive inputs as accessors: the `target` argument of `useTrait`, `useTraitEffect`, `useTag`, `useHas`, `useTarget`, and `useTargets` accepts `Entity | World` or an accessor returning one. -- The `trait` argument of `useTrait`, `useTraitEffect`, and `useHas` also accepts an accessor, which matters for relation pairs with reactive targets: `useTrait(entity, () => ChildOf(parent()))`. Pair identity is compared by relation + target, so re-evaluations with the same pair do not resubscribe. -- `useTraitEffect` runs its callback untracked, like the React version. Cleanup is tied to the owning component. - -## Exports - -`WorldProvider`, `useWorld`, `useActions`, `useQuery`, `useQueryFirst`, `useTrait`, `useTraitEffect`, `useTag`, `useHas`, `useTarget`, `useTargets`, plus the `MaybeAccessor` type and `access` helper. diff --git a/packages/koota-solid/package.json b/packages/koota-solid/package.json deleted file mode 100644 index 4e57846c..00000000 --- a/packages/koota-solid/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@diffusionstudio/koota-solid", - "version": "0.1.0", - "description": "Solid bindings for koota — a port of @koota/react, adapted to Solid's reactivity model.", - "license": "MPL-2.0", - "type": "module", - "sideEffects": false, - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "check": "tsc --noEmit" - }, - "keywords": [ - "koota", - "ecs", - "solid-js" - ], - "dependencies": { - "koota": "^0.6.6" - }, - "peerDependencies": { - "solid-js": "^1.9.0" - }, - "devDependencies": { - "typescript": "~5.9.3" - } -} diff --git a/packages/koota/LICENSE b/packages/koota/LICENSE new file mode 100644 index 00000000..655ac3c9 --- /dev/null +++ b/packages/koota/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2024-2025 Poimandres + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/koota/README.md b/packages/koota/README.md new file mode 100644 index 00000000..87c30b24 --- /dev/null +++ b/packages/koota/README.md @@ -0,0 +1,165 @@ +# @diffusionstudio/koota + +A vendored fork of [pmndrs/koota](https://github.com/pmndrs/koota) reduced to the +core ECS library, plus the in-house Solid bindings that used to live in +`packages/koota-solid`. + +| Entry point | Source | Origin | +| --- | --- | --- | +| `@diffusionstudio/koota` | `src/index.ts` | Vendored upstream core (ISC) | +| `@diffusionstudio/koota/solid` | `src/solid/index.ts` | Ours, a port of `@koota/react` (MPL-2.0) | + +Upstream commit: [`460a0c8`](https://github.com/pmndrs/koota/commit/460a0c8cca7839debffab304ed83dd8bbd0a3d30) +(the `koota@0.6.6` line). + +## What was kept + +- `packages/core/src` → `src` — the whole vanilla core: world, entities, traits, + queries, relations, storage, actions. +- `packages/collections/src` → `src/collections` — `SparseSet`, `Deque` and + `HiSparseBitSet`, the only internal package core depends on. Upstream imports of + `@koota/collections` were rewritten to relative paths. + +## What was dropped + +React bindings (`@koota/react` and the `koota/react` entry point — nothing here +uses them; we use the Solid bindings below instead), the `publish` +package and its `tsdown` build, tests, benches, docs, examples and upstream repo +tooling. The public API is unchanged: `src/index.ts` is upstream's core entry +point, which is all the published `koota` package re-exported. + +There is no build step — the package is consumed as TypeScript source, like every +other package in this workspace. + +## `QueryResult.sortBy` — cached ordering + +`sortBy` orders a query result by a trait value: + +```ts +const children = world.query(ChildOf(parent)).sortBy(OrderIndex, 'value', 'asc'); +``` + +It returns a `QueryResult` with exactly the same shape as the unsorted one, so +`readEach`, `updateEach`, `useStores`, `select`, `sort` and array access all work as +usual. The direction defaults to `'asc'`. + +The order is cached per world, keyed by the source entity set plus the trait, key and +direction, and is only recomputed when one of two things happens: + +- the matched entities change — tracked through the query's version counter, or, for + the single-relation-pair fast path (`world.query(ChildOf(parent))`), through a + per-target version on the relation's reverse index; +- a sort value moves on an entity that is currently in the order — tracked through + `onChange` / `onAdd` / `onRemove` subscriptions on the sort trait, filtered by + membership so unrelated entities do not invalidate anything. + +When neither happened, `sortBy` is a version comparison and hands back the same result +object as last time. `world.query(...)` still allocates its own snapshot array before +`sortBy` runs, so the end-to-end call stays O(n) — but the sorting itself drops out. +At 20k children: 21.1 µs for the bare query, 20.4 µs for query + `sortBy` on a cache +hit, 239 µs for query + `sort()` with a comparator. + +Details worth knowing: + +- **The returned result is owned by the cache.** Its backing array is mutated in place + when the order is recomputed. Copy it (`.slice()`) if you need a stable snapshot. +- **The sort is stable.** Entities that compare equal keep their order from the query, + which is insertion order for a relation pair. +- **Entities without the trait sort last**, in both directions, as do `null`, + `undefined` and `NaN`. Presence is read from the entity bitmask, not from the store, + since stores keep stale values after a trait is removed and entity ids are recycled. +- **Chaining sorts by more than one key**, least significant first: + `.sortBy(Name, 'last').sortBy(Name, 'first')`. Each link gets its own cache entry. +- **Subscribing to the sort trait marks it tracked**, so `updateEach` runs change + detection on it for the lifetime of the world. That is what makes writes through + `updateEach` invalidate the order. +- **Tracking queries** (`Added`/`Removed`/`Changed`) rebuild their result on every run, + so there is nothing stable to cache against; `sortBy` sorts them in place instead. +- `world.reset()` clears every cached order. + +## Fork changes + +One behavioural change on top of upstream, previously carried as +`patches/koota+0.6.6.patch` against the built `node_modules/koota` and now folded +into the source (that patch file has been deleted): + +- `src/query/utils/check-query.ts` and `src/query/utils/check-query-tracking.ts` — + `Or(...)` is a query-wide constraint, but static bitmasks are stored per trait + generation. Upstream returns `false` as soon as any one generation's `Or` mask + fails to match, so an `Or` spanning traits in two generations can never match. + The fork accumulates `Or` state across the whole generation loop and rejects + only if no generation matched. The relation-filtered variants delegate to these + two functions, so they inherit the fix. + +## The `sideEffects` field is deliberately narrow + +The manifest declares `"sideEffects": ["**/entity-methods-patch.ts"]` rather than +the `false` used by the other packages here. `src/entity/entity-methods-patch.ts` +is imported purely for its side effect — it patches `Number.prototype` with the +entity methods (`has`, `add`, `get`, `set`, ...) so a raw number can act as an +entity. Declaring the package side-effect-free lets bundlers tree-shake that +import away, which breaks every entity method at runtime while still typechecking +cleanly. Listing just that one file keeps the rest of core and all of the Solid +bindings tree-shakeable. It is the only side-effect-only import in the package. + +## Upstream `@inline` annotations + +Core carries `/* @inline */` comments used by upstream's +`unplugin-inline-functions` build plugin. That plugin is not wired up here, so the +annotations are inert; they are left in place to keep diffs against upstream clean. + +## License + +This package is dual-origin, so the manifest declares `ISC AND MPL-2.0`: + +- Everything outside `src/solid` is vendored koota, ISC licensed, + Copyright (c) 2024-2025 Poimandres — see [LICENSE](./LICENSE). +- `src/solid` is our own port of `@koota/react`, MPL-2.0 like the rest of this + repository. + +--- + +# Solid bindings — `@diffusionstudio/koota/solid` + + +Solid bindings for [koota](https://github.com/pmndrs/koota): a port of `@koota/react`. The API mirrors `@koota/react` one to one, adapted to Solid's reactivity model. + +## Usage + +```tsx +import { createWorld, trait } from '@diffusionstudio/koota'; +import { WorldProvider, useQuery, useTrait } from '@diffusionstudio/koota/solid'; + +const Position = trait({ x: 0, y: 0 }); +const world = createWorld(); + +function App() { + return ( + + + + ); +} + +function EntityList() { + const entities = useQuery(Position); + return {(entity) => }; +} + +function EntityView(props: { entity: Entity }) { + // Pass reactive inputs as accessors so the hook resubscribes when they change + const pos = useTrait(() => props.entity, Position); + return
{pos()?.x}, {pos()?.y}
; +} +``` + +## Differences from `@koota/react` + +- Every hook returns an `Accessor` instead of a plain value: call it (`pos()`, `entities()`) inside JSX, memos, or effects to subscribe. +- Solid components run once, so hooks are set up once. Where React re-renders with new props, here you pass reactive inputs as accessors: the `target` argument of `useTrait`, `useTraitEffect`, `useTag`, `useHas`, `useTarget`, and `useTargets` accepts `Entity | World` or an accessor returning one. +- The `trait` argument of `useTrait`, `useTraitEffect`, and `useHas` also accepts an accessor, which matters for relation pairs with reactive targets: `useTrait(entity, () => ChildOf(parent()))`. Pair identity is compared by relation + target, so re-evaluations with the same pair do not resubscribe. +- `useTraitEffect` runs its callback untracked, like the React version. Cleanup is tied to the owning component. + +## Exports + +`WorldProvider`, `useWorld`, `useActions`, `useQuery`, `useQueryFirst`, `useTrait`, `useTraitEffect`, `useTag`, `useHas`, `useTarget`, `useTargets`, plus the `MaybeAccessor` type and `access` helper. diff --git a/packages/koota/package.json b/packages/koota/package.json new file mode 100644 index 00000000..c948c60d --- /dev/null +++ b/packages/koota/package.json @@ -0,0 +1,33 @@ +{ + "name": "@diffusionstudio/koota", + "version": "0.6.6", + "description": "Local fork of the koota core ECS (pmndrs/koota), plus the in-house Solid bindings at ./solid. No build step; consumed as TypeScript source.", + "license": "ISC AND MPL-2.0", + "type": "module", + "sideEffects": [ + "**/entity-methods-patch.ts" + ], + "exports": { + ".": "./src/index.ts", + "./solid": "./src/solid/index.ts" + }, + "scripts": { + "check": "tsc --noEmit" + }, + "keywords": [ + "koota", + "ecs", + "solid-js" + ], + "peerDependencies": { + "solid-js": "^1.9.0" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + }, + "devDependencies": { + "typescript": "~5.9.3" + } +} diff --git a/packages/koota/src/actions/create-actions.ts b/packages/koota/src/actions/create-actions.ts new file mode 100644 index 00000000..d65b6943 --- /dev/null +++ b/packages/koota/src/actions/create-actions.ts @@ -0,0 +1,46 @@ +import { $internal } from '../common'; +import type { World } from '../world'; +import type { Actions, ActionsInitializer, ActionRecord } from './types'; + +let actionsId = 0; + +export function createActions( + initializer: ActionsInitializer +): Actions { + const id = actionsId++; + + const actions = Object.assign( + (world: World): T => { + const ctx = world[$internal]; + + // Try array lookup first (faster) + let instance = ctx.actionInstances[id]; + + if (!instance) { + // Create and cache actions instance + instance = initializer(world); + + // Ensure array is large enough + if (id >= ctx.actionInstances.length) { + ctx.actionInstances.length = id + 1; + } + ctx.actionInstances[id] = instance; + } + + return instance as T; + }, + { + initializer, + } + ) as Actions; + + // Add public read-only id property + Object.defineProperty(actions, 'id', { + value: id, + writable: false, + enumerable: true, + configurable: false, + }); + + return actions; +} diff --git a/packages/koota/src/actions/types.ts b/packages/koota/src/actions/types.ts new file mode 100644 index 00000000..6097b431 --- /dev/null +++ b/packages/koota/src/actions/types.ts @@ -0,0 +1,12 @@ +import type { World } from '../world'; + +export type ActionRecord = Record void>; +export type ActionsInitializer = (world: World) => T; +export type Actions = { + /** Public read-only ID for fast array lookups */ + readonly id: number; + /** Initializer function */ + readonly initializer: ActionsInitializer; +} & ((world: World) => T); + +export type ActionInstance = ActionRecord; diff --git a/packages/koota/src/collections/deque.ts b/packages/koota/src/collections/deque.ts new file mode 100644 index 00000000..43021cf3 --- /dev/null +++ b/packages/koota/src/collections/deque.ts @@ -0,0 +1,31 @@ +export class Deque { + #removed: T[] = []; + #removedOut: T[] = []; + + dequeue(): T { + if (this.#removedOut.length === 0) { + while (this.#removed.length > 0) { + this.#removedOut.push(this.#removed.pop()!); + } + } + + if (this.#removedOut.length === 0) { + throw new Error('Queue is empty'); + } + + return this.#removedOut.pop()!; + } + + enqueue(...items: T[]): void { + this.#removed.push(...items); + } + + get length(): number { + return this.#removed.length + this.#removedOut.length; + } + + clear(): void { + this.#removed.length = 0; + this.#removedOut.length = 0; + } +} diff --git a/packages/koota/src/collections/hi-sparse-bitset.ts b/packages/koota/src/collections/hi-sparse-bitset.ts new file mode 100644 index 00000000..91f8db52 --- /dev/null +++ b/packages/koota/src/collections/hi-sparse-bitset.ts @@ -0,0 +1,440 @@ +/** + * Hierarchical Sparse BitSet + * Credit goes to @tower120 for https://github.com/tower120/hi_sparse_bitset + * + * This is just a typescript rewrite of that library. + */ + +/** Count trailing zeros — position of lowest set bit. */ +/* @inline @pure */ export function ctz32(v: number): number { + if (v === 0) return 32; + return 31 - Math.clz32(v & -v); +} + +/** Population count — number of set bits in a 32-bit integer. */ +/* @inline @pure */ function popcount32(v: number): number { + const a = v - ((v >>> 1) & 0x55555555); + const b = (a & 0x33333333) + ((a >>> 2) & 0x33333333); + return (((b + (b >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; +} + +/** Static empty block for branchless reads. */ +const EMPTY_BLOCK = new Uint32Array(32); + +export class HiSparseBitSet { + /** L0 summary: bit i set means l1Summary[i] has data. */ + l0: number = 0; + + /** L1 summary: l1Summary[l0i] bit j set means l2 block (l0i*32 + j) has data. */ + l1Summary: Uint32Array = new Uint32Array(32); + + /** L2 data blocks. l2Blocks[l1i] is a 32-element Uint32Array (pre-allocated to 32 null slots). */ + l2Blocks: (Uint32Array | null)[] = new Array(32).fill(null); + + private _size: number = 0; + + get size(): number { + return this._size; + } + + /** Insert an entity index into the set. O(1) amortized. */ + insert(index: number): void { + const l0i = index >>> 15; + const l1i = (index >>> 10) & 31; + const blockIdx = l0i === 0 ? l1i : (l0i << 5) | l1i; + + // Allocate L2 block if needed + let block = this.l2Blocks[blockIdx]; + if (block === null || block === undefined) { + block = new Uint32Array(32); + this.l2Blocks[blockIdx] = block; + } + + const l2i = (index >>> 5) & 31; + const mask = 1 << (index & 31); + const word = block[l2i]; + + if ((word & mask) === 0) { + block[l2i] = word | mask; + this.l1Summary[l0i] |= 1 << l1i; + this.l0 |= 1 << l0i; + this._size++; + } + } + + /** Remove an entity index from the set. O(1). */ + remove(index: number): void { + const l0i = index >>> 15; + const l1i = (index >>> 10) & 31; + const blockIdx = l0i === 0 ? l1i : (l0i << 5) | l1i; + const block = this.l2Blocks[blockIdx]; + if (block === null || block === undefined) return; + + const l2i = (index >>> 5) & 31; + const mask = 1 << (index & 31); + const word = block[l2i]; + + if ((word & mask) !== 0) { + block[l2i] = word & ~mask; + this._size--; + + // Check if entire L2 block is now empty + if (block[l2i] === 0) { + // Scan the block to see if any words remain + let blockEmpty = true; + for (let i = 0; i < 32; i++) { + if (block[i] !== 0) { + blockEmpty = false; + break; + } + } + + if (blockEmpty) { + this.l1Summary[l0i] &= ~(1 << l1i); + + // Check if entire L1 group is now empty + if (this.l1Summary[l0i] === 0) { + this.l0 &= ~(1 << l0i); + } + } + } + } + } + + /** Test membership. O(1). */ + has(index: number): boolean { + const l0i = index >>> 15; + const blockIdx = l0i === 0 ? (index >>> 10) & 31 : (l0i << 5) | ((index >>> 10) & 31); + const block = this.l2Blocks[blockIdx]; + if (block === null || block === undefined) return false; + return (block[(index >>> 5) & 31] & (1 << (index & 31))) !== 0; + } + + /** Clear all entries. */ + clear(): void { + this.l0 = 0; + this.l1Summary.fill(0); + for (let i = 0; i < this.l2Blocks.length; i++) { + const block = this.l2Blocks[i]; + if (block !== null && block !== undefined) block.fill(0); + } + this._size = 0; + } + + /** Get L2 data block for branchless reads. Returns EMPTY_BLOCK if not allocated. */ + getBlock(blockIdx: number): Uint32Array { + return this.l2Blocks[blockIdx] ?? EMPTY_BLOCK; + } + + /** Create a deep copy of this bitset. */ + clone(): HiSparseBitSet { + const copy = new HiSparseBitSet(); + copy.l0 = this.l0; + copy.l1Summary = new Uint32Array(this.l1Summary); + copy._size = this._size; + for (let i = 0; i < this.l2Blocks.length; i++) { + const block = this.l2Blocks[i]; + if (block !== null && block !== undefined) { + copy.l2Blocks[i] = new Uint32Array(block); + } + } + return copy; + } + + /** Iterate all set indices in sorted order via callback. Zero allocation. */ + forEach(callback: (index: number) => void): void { + let l0Bits = this.l0; + while (l0Bits !== 0) { + const l0i = ctz32(l0Bits); + l0Bits &= l0Bits - 1; + + let l1Bits = this.l1Summary[l0i]; + while (l1Bits !== 0) { + const l1i = ctz32(l1Bits); + l1Bits &= l1Bits - 1; + + const blockIdx = (l0i << 5) | l1i; + const block = this.l2Blocks[blockIdx]!; + + for (let l2i = 0; l2i < 32; l2i++) { + let word = block[l2i]; + if (word === 0) continue; + + const base = (l0i << 15) | (l1i << 10) | (l2i << 5); + while (word !== 0) { + const bit = ctz32(word); + word &= word - 1; + callback(base | bit); + } + } + } + } + } + + /** Iterate all set bits in ascending order AND clear them. After drain the bitset is empty. */ + drain(callback: (index: number) => void): void { + let l0Bits = this.l0; + if (l0Bits === 0) return; + + while (l0Bits !== 0) { + const l0i = ctz32(l0Bits); + l0Bits &= l0Bits - 1; + + let l1Bits = this.l1Summary[l0i]; + while (l1Bits !== 0) { + const l1i = ctz32(l1Bits); + l1Bits &= l1Bits - 1; + + const blockIdx = (l0i << 5) | l1i; + const block = this.l2Blocks[blockIdx]!; + + for (let l2i = 0; l2i < 32; l2i++) { + let word = block[l2i]; + if (word === 0) continue; + + const base = (l0i << 15) | (l1i << 10) | (l2i << 5); + while (word !== 0) { + const bit = ctz32(word); + word &= word - 1; + callback(base | bit); + } + block[l2i] = 0; + } + } + this.l1Summary[l0i] = 0; + } + + this.l0 = 0; + this._size = 0; + } + + /** + * Set a contiguous range [start, end) using word-level ops. + * A 100-entity subtree = ~3 word fills + 2 partial ORs vs 100 individual insert() calls. + */ + setRange(start: number, end: number): void { + if (start >= end) return; + + for (let idx = start; idx < end;) { + const l0i = idx >>> 15; + const l1i = (idx >>> 10) & 31; + const l2i = (idx >>> 5) & 31; + const bit = idx & 31; + + const blockIdx = (l0i << 5) | l1i; + + let block = this.l2Blocks[blockIdx]; + if (block === null || block === undefined) { + block = new Uint32Array(32); + this.l2Blocks[blockIdx] = block; + } + + // How many indices remain in this L2 word? + const wordEnd = Math.min(end, (idx & ~31) + 32); + const count = wordEnd - idx; + + if (bit === 0 && count >= 32) { + // Full word fill + const prev = block[l2i]; + if (prev !== ~0 >>> 0) { + const added = 32 - popcount32(prev); + block[l2i] = ~0 >>> 0; + this._size += added; + } + } else { + // Partial word: bits [bit, bit + count - 1] + const endBit = bit + count - 1; + const mask = ((2 << endBit) - 1) & ~((1 << bit) - 1); + const prev = block[l2i]; + const newWord = prev | mask; + if (newWord !== prev) { + this._size += popcount32(newWord) - popcount32(prev); + block[l2i] = newWord; + } + } + + this.l1Summary[l0i] |= 1 << l1i; + this.l0 |= 1 << l0i; + + idx = wordEnd; + } + } +} + +/** + * High-performance N-way intersection iteration via callback. + * Prunes at each hierarchy level — only visits entity IDs present in ALL sets. + * Zero allocation during iteration. + * + * Returns the number of entities visited. + */ +export function forEachIntersection( + sets: HiSparseBitSet[], + callback: (entityId: number) => void +): number { + const n = sets.length; + if (n === 0) return 0; + + let count = 0; + + // Pre-extract l2Blocks arrays to avoid repeated property access + const allL2Blocks: (Uint32Array | null)[][] = new Array(n); + for (let i = 0; i < n; i++) allL2Blocks[i] = sets[i].l2Blocks; + + // AND all L0 masks + let l0Combined = ~0 >>> 0; + for (let i = 0; i < n; i++) l0Combined &= sets[i].l0; + + while (l0Combined !== 0) { + const l0i = ctz32(l0Combined); + l0Combined &= l0Combined - 1; + + // AND all L1 summaries for this L0 slot + let l1Combined = ~0 >>> 0; + for (let i = 0; i < n; i++) l1Combined &= sets[i].l1Summary[l0i]; + + while (l1Combined !== 0) { + const l1i = ctz32(l1Combined); + l1Combined &= l1Combined - 1; + + const blockIdx = (l0i << 5) | l1i; + + // Pre-resolve all blocks for this blockIdx — one branch per set, not 32×n + let skipBlock = false; + for (let i = 0; i < n; i++) { + const blk = allL2Blocks[i][blockIdx]; + if (blk === null || blk === undefined) { + skipBlock = true; + break; + } + } + if (skipBlock) continue; + + // AND all L2 data words — blocks guaranteed non-null + for (let l2i = 0; l2i < 32; l2i++) { + let word = ~0 >>> 0; + for (let i = 0; i < n; i++) { + word &= allL2Blocks[i][blockIdx]![l2i]; + if (word === 0) break; + } + + if (word === 0) continue; + + const base = (l0i << 15) | (l1i << 10) | (l2i << 5); + while (word !== 0) { + const bit = ctz32(word); + word &= word - 1; + callback(base | bit); + count++; + } + } + } + } + + return count; +} + +/** + * N-way intersection with forbidden set exclusion. + * The full ECS query pattern: required(A, B, C) AND NOT(D, E). + * + * Returns the number of entities visited. + */ +export function forEachQuery( + required: HiSparseBitSet[], + forbidden: HiSparseBitSet[], + callback: (entityId: number) => void +): number { + const nReq = required.length; + const nForb = forbidden.length; + if (nReq === 0) return 0; + + let count = 0; + + // Pre-extract l2Blocks arrays to avoid repeated property access + const reqL2: (Uint32Array | null)[][] = new Array(nReq); + for (let i = 0; i < nReq; i++) reqL2[i] = required[i].l2Blocks; + const forbL2: (Uint32Array | null)[][] = new Array(nForb); + for (let i = 0; i < nForb; i++) forbL2[i] = forbidden[i].l2Blocks; + + // AND all required L0 masks + let l0Combined = ~0 >>> 0; + for (let i = 0; i < nReq; i++) l0Combined &= required[i].l0; + + while (l0Combined !== 0) { + const l0i = ctz32(l0Combined); + l0Combined &= l0Combined - 1; + + // AND all required L1 summaries + let l1Combined = ~0 >>> 0; + for (let i = 0; i < nReq; i++) l1Combined &= required[i].l1Summary[l0i]; + + while (l1Combined !== 0) { + const l1i = ctz32(l1Combined); + l1Combined &= l1Combined - 1; + + const blockIdx = (l0i << 5) | l1i; + + // Pre-resolve required blocks — if any is null, skip entire block + let skipBlock = false; + for (let i = 0; i < nReq; i++) { + const blk = reqL2[i][blockIdx]; + if (blk === null || blk === undefined) { + skipBlock = true; + break; + } + } + if (skipBlock) continue; + + for (let l2i = 0; l2i < 32; l2i++) { + // AND required data words — blocks guaranteed non-null + let word = ~0 >>> 0; + for (let i = 0; i < nReq; i++) { + word &= reqL2[i][blockIdx]![l2i]; + if (word === 0) break; + } + + // ANDNOT forbidden data words (null block = 0, no effect) + if (word !== 0) { + for (let i = 0; i < nForb; i++) { + const fBlock = forbL2[i][blockIdx]; + if (fBlock !== null && fBlock !== undefined) { + word &= ~fBlock[l2i]; + if (word === 0) break; + } + } + } + + if (word === 0) continue; + + const base = (l0i << 15) | (l1i << 10) | (l2i << 5); + while (word !== 0) { + const bit = ctz32(word); + word &= word - 1; + callback(base | bit); + count++; + } + } + } + } + + return count; +} + +/** + * Collect intersection results into an array. + */ +export function collectIntersection(sets: HiSparseBitSet[]): number[] { + const result: number[] = []; + forEachIntersection(sets, (eid) => result.push(eid)); + return result; +} + +/** + * Collect query results (required + forbidden) into an array. + */ +export function collectQuery(required: HiSparseBitSet[], forbidden: HiSparseBitSet[]): number[] { + const result: number[] = []; + forEachQuery(required, forbidden, (eid) => result.push(eid)); + return result; +} diff --git a/packages/koota/src/collections/index.ts b/packages/koota/src/collections/index.ts new file mode 100644 index 00000000..dfbf1aee --- /dev/null +++ b/packages/koota/src/collections/index.ts @@ -0,0 +1,10 @@ +export { Deque } from './deque'; +export { SparseSet } from './sparse-set'; +export { + HiSparseBitSet, + ctz32, + forEachIntersection, + forEachQuery, + collectIntersection, + collectQuery, +} from './hi-sparse-bitset'; diff --git a/packages/koota/src/collections/sparse-set.ts b/packages/koota/src/collections/sparse-set.ts new file mode 100644 index 00000000..2d1da5fe --- /dev/null +++ b/packages/koota/src/collections/sparse-set.ts @@ -0,0 +1,68 @@ +export class SparseSet { + _dense: number[] = []; + _sparse: number[] = []; + _cursor: number = 0; + _denseRaw: { array: number[]; length: number } = { array: this._dense, length: 0 }; + + has(val: number): boolean { + const index = this._sparse[val]; + return index < this._cursor && this._dense[index] === val; + } + + add(val: number): void { + if (this.has(val)) return; + this._sparse[val] = this._cursor; + this._dense[this._cursor++] = val; + } + + remove(val: number): void { + if (!this.has(val)) return; + const index = this._sparse[val]; + this._cursor--; + const swapped = this._dense[this._cursor]; + if (swapped !== val) { + this._dense[index] = swapped; + this._sparse[swapped] = index; + } + } + + clear(): void { + // Clear the sparse array entries for all active values + for (let i = 0; i < this._cursor; i++) { + this._sparse[this._dense[i]] = 0; + } + this._cursor = 0; + } + + sort(): void { + this._dense.sort((a, b) => a - b); + for (let i = 0; i < this._dense.length; i++) { + this._sparse[this._dense[i]] = i; + } + } + + getIndex(val: number): number { + return this._sparse[val]; + } + + get dense(): number[] { + return this._dense.slice(0, this._cursor); + } + + get denseRaw(): { array: number[]; length: number } { + this._denseRaw.length = this._cursor; + return this._denseRaw; + } + + get rawDense(): readonly number[] { + return this._dense; + } + + get length(): number { + return this._cursor; + } + + get sparse(): number[] { + return this._sparse; + } +} diff --git a/packages/koota/src/common.ts b/packages/koota/src/common.ts new file mode 100644 index 00000000..800960e3 --- /dev/null +++ b/packages/koota/src/common.ts @@ -0,0 +1,7 @@ +export const $internal = Symbol.for('koota.internal'); + +/** + * Type utility for symbol-branded runtime type checks. + * Allows accessing a symbol property while maintaining type safety. + */ +export type Brand = { readonly [K in S]?: true }; diff --git a/packages/koota/src/entity/entity-methods-patch.ts b/packages/koota/src/entity/entity-methods-patch.ts new file mode 100644 index 00000000..a1853db2 --- /dev/null +++ b/packages/koota/src/entity/entity-methods-patch.ts @@ -0,0 +1,85 @@ +// Add methods to the Number prototype so it can be used as an entity. +// This lets us keep the performance of raw numbers over using objects +// and the convenience of using methods. Type guards are used to ensure +// that the methods are only called on entities. + +import { $internal } from '../common'; +import { setChanged } from '../query/modifiers/changed'; +import { getFirstRelationTarget, getRelationTargets, hasRelationPair } from '../relation/relation'; +import type { Relation, RelationPair } from '../relation/types'; +import { isRelationPair } from '../relation/utils/is-relation'; +import { addTrait, getTrait, hasTrait, removeTrait, setTrait } from '../trait/trait'; +import type { ConfigurableTrait, Trait } from '../trait/types'; +import { destroyEntity, getEntityWorld } from './entity'; +import type { Entity } from './types'; +import { isEntityAlive } from './utils/entity-index'; +import { getEntityGeneration, getEntityId } from './utils/pack-entity'; + +// @ts-expect-error +Number.prototype.add = function (this: Entity, ...traits: ConfigurableTrait[]) { + return addTrait(getEntityWorld(this), this, ...traits); +}; + +// @ts-expect-error +Number.prototype.remove = function (this: Entity, ...traits: (Trait | RelationPair)[]) { + return removeTrait(getEntityWorld(this), this, ...traits); +}; + +// @ts-expect-error +Number.prototype.has = function (this: Entity, trait: Trait | RelationPair) { + const world = getEntityWorld(this); + if (isRelationPair(trait)) return hasRelationPair(world, this, trait); + return /* @inline @pure */ hasTrait(world, this, trait); +}; + +// @ts-expect-error +Number.prototype.destroy = function (this: Entity) { + return destroyEntity(getEntityWorld(this), this); +}; + +// @ts-expect-error +Number.prototype.changed = function (this: Entity, trait: Trait) { + return setChanged(getEntityWorld(this), this, trait); +}; + +// @ts-expect-error +Number.prototype.get = function (this: Entity, trait: Trait | RelationPair) { + return getTrait(getEntityWorld(this), this, trait); +}; + +// @ts-expect-error +Number.prototype.set = function ( + this: Entity, + trait: Trait | RelationPair, + value: any, + triggerChanged = true +) { + setTrait(getEntityWorld(this), this, trait, value, triggerChanged); +}; + +//@ts-expect-error +Number.prototype.targetsFor = function (this: Entity, relation: Relation) { + return getRelationTargets(getEntityWorld(this), relation, this); +}; + +//@ts-expect-error +Number.prototype.targetFor = function (this: Entity, relation: Relation) { + return getFirstRelationTarget(getEntityWorld(this), relation, this); +}; + +//@ts-expect-error +Number.prototype.id = function (this: Entity) { + return getEntityId(this); +}; + +// @ts-expect-error +Number.prototype.generation = function (this: Entity) { + return getEntityGeneration(this); +}; + +//@ts-expect-error +Number.prototype.isAlive = function (this: Entity) { + const world = getEntityWorld(this); + const entityIndex = world[$internal].entityIndex; + return isEntityAlive(entityIndex, this); +}; diff --git a/packages/koota/src/entity/entity.ts b/packages/koota/src/entity/entity.ts new file mode 100644 index 00000000..30ba5502 --- /dev/null +++ b/packages/koota/src/entity/entity.ts @@ -0,0 +1,115 @@ +import { $internal } from '../common'; +import { getEntitiesWithRelationTo, getRelationTargets } from '../relation/relation'; +import { addTrait, cleanupRelationTarget, removeTrait } from '../trait/trait'; +import type { ConfigurableTrait } from '../trait/types'; +import { universe } from '../universe/universe'; +import type { World } from '../world'; +import type { Entity } from './types'; +import { allocateEntity, releaseEntity } from './utils/entity-index'; +import { getEntityId, getEntityWorldId } from './utils/pack-entity'; + +// Ensure entity methods are patched. +import './entity-methods-patch'; + +export function createEntity(world: World, ...traits: ConfigurableTrait[]): Entity { + const ctx = world[$internal]; + const entity = allocateEntity(ctx.entityIndex); + + for (const query of ctx.notQueries) { + const match = query.check(world, entity); + if (match) query.add(entity); + // Reset all tracking bitmasks for the query. + query.resetTrackingBitmasks(getEntityId(entity)); + } + + ctx.entityTraits.set(entity, new Set()); + addTrait(world, entity, ...traits); + + return entity; +} + +const cachedSet = new Set(); +const cachedQueue = [] as Entity[]; + +export function destroyEntity(world: World, entity: Entity) { + const ctx = world[$internal]; + + // Check if entity exists. + if (!world.has(entity)) throw new Error('Koota: The entity being destroyed does not exist.'); + + // Caching the lookup in the outer scope of the loop increases performance. + const entityQueue = cachedQueue; + const processedEntities = cachedSet; + + // Ensure the queue is empty before starting. + entityQueue.length = 0; + entityQueue.push(entity); + processedEntities.clear(); + + // Destroyed entities may be the target or source of relations. + // To avoid stale references, all these relations must be removed. + // autoDestroy controls cascade behavior: + // - 'source' (or 'orphan'): when target dies, destroy sources (e.g., parent dies → children die) + // - 'target': when source dies, destroy targets (e.g., container dies → items die) + while (entityQueue.length > 0) { + const currentEntity = entityQueue.pop()!; + if (processedEntities.has(currentEntity)) continue; + + processedEntities.add(currentEntity); + + for (const relation of ctx.relations) { + const relationCtx = relation[$internal]; + + // Handle entities that have relations pointing TO currentEntity (currentEntity is target) + // If autoDestroy is 'orphan', destroy those sources + const sources = getEntitiesWithRelationTo(world, relation, currentEntity); + for (let si = sources.length - 1; si >= 0; si--) { + const source = sources[si]; + if (!world.has(source)) continue; + + cleanupRelationTarget(world, relation, source, currentEntity); + + if (relationCtx.autoDestroy === 'source') entityQueue.push(source); + } + + // Handle relations where currentEntity is the source pointing to targets + // If autoDestroy is 'target', destroy those targets + if (relationCtx.autoDestroy === 'target') { + const targets = getRelationTargets(world, relation, currentEntity); + for (const target of targets) { + if (!world.has(target)) continue; + if (!processedEntities.has(target)) entityQueue.push(target); + } + } + } + + // Remove all traits of the current entity. + const entityTraits = ctx.entityTraits.get(currentEntity); + if (entityTraits) { + for (const trait of entityTraits) { + removeTrait(world, currentEntity, trait); + } + } + + // Free the entity. + releaseEntity(ctx.entityIndex, currentEntity); + + // Remove the entity from the all query. + const allQuery = ctx.queriesHashMap.get(''); + if (allQuery) allQuery.remove(world, currentEntity); + + // Remove all entity state from world. + ctx.entityTraits.delete(currentEntity); + + // Clear entity bitmasks. + const eid = getEntityId(currentEntity); + for (let i = 0; i < ctx.entityMasks.length; i++) { + ctx.entityMasks[i][eid] = 0; + } + } +} + +/* @inline @pure */ export function getEntityWorld(entity: Entity) { + const worldId = getEntityWorldId(entity); + return universe.worlds[worldId]!; +} diff --git a/packages/koota/src/entity/types.ts b/packages/koota/src/entity/types.ts new file mode 100644 index 00000000..8c958fec --- /dev/null +++ b/packages/koota/src/entity/types.ts @@ -0,0 +1,28 @@ +import type { Relation, RelationPair } from '../relation/types'; +import type { + ConfigurableTrait, + ExtractSchema, + SetTraitCallback, + Trait, + TraitRecord, + TraitValue, +} from '../trait/types'; + +export type Entity = number & { + add: (...traits: ConfigurableTrait[]) => void; + remove: (...traits: (Trait | RelationPair)[]) => void; + has: (trait: Trait | RelationPair) => boolean; + destroy: () => void; + changed: (trait: Trait) => void; + set: ( + trait: T, + value: TraitValue> | SetTraitCallback, + flagChanged?: boolean + ) => void; + get: (trait: T) => TraitRecord> | undefined; + targetFor: (relation: Relation) => Entity | undefined; + targetsFor: (relation: Relation) => Entity[]; + id: () => number; + generation: () => number; + isAlive: () => boolean; +}; diff --git a/packages/koota/src/entity/utils/entity-index.ts b/packages/koota/src/entity/utils/entity-index.ts new file mode 100644 index 00000000..1432aada --- /dev/null +++ b/packages/koota/src/entity/utils/entity-index.ts @@ -0,0 +1,107 @@ +import type { Entity } from '../types'; +import { + getEntityGeneration, + getEntityId, + getEntityWorldId, + incrementGeneration, + packEntity, +} from './pack-entity'; + +export type EntityIndex = { + /** The number of currently alive entities. */ + aliveCount: number; + /** Array of packed entities, densely packed. */ + dense: Entity[]; + /** Sparse array mapping entity IDs to their index in the dense array. */ + sparse: number[]; + /** The highest entity ID that has been assigned. */ + maxId: number; + /** The current world ID. */ + worldId: number; +}; + +/** + * Creates and initializes a new EntityIndex. + * @param worldId - The ID of the world this index belongs to. + * @returns A new EntityIndex object. + */ +export const createEntityIndex = (worldId: number): EntityIndex => ({ + aliveCount: 0, + dense: [], + sparse: [], + maxId: 0, + worldId, +}); + +/** + * Adds a new entity ID to the index or recycles an existing one. + * @param index - The EntityIndex to add to. + * @returns The new or recycled packed entity. + */ +export const allocateEntity = (index: EntityIndex): Entity => { + if (index.aliveCount < index.dense.length) { + // Recycle entity + const recycledEntity = incrementGeneration(index.dense[index.aliveCount]); + index.dense[index.aliveCount] = recycledEntity; + index.sparse[getEntityId(recycledEntity)] = index.aliveCount; + index.aliveCount++; + + return recycledEntity; + } + // Create new entity + const id = index.maxId++; + const entity = packEntity(index.worldId, 0, id); + index.dense.push(entity); + index.sparse[id] = index.aliveCount; + index.aliveCount++; + + return entity; +}; + +/** + * Removes an entity ID from the index. + * @param index - The EntityIndex to remove from. + * @param entity - The packed entity to remove. + */ +export const releaseEntity = (index: EntityIndex, entity: Entity): void => { + const id = getEntityId(entity); + const denseIndex = index.sparse[id]; + if (denseIndex === undefined || denseIndex >= index.aliveCount) return; + + const lastIndex = index.aliveCount - 1; + const lastEntity = index.dense[lastIndex]; + const lastId = getEntityId(lastEntity); + + // Swap with the last element + index.sparse[lastId] = denseIndex; + index.dense[denseIndex] = lastEntity; + // Update the removed entity's record + index.sparse[id] = lastIndex; + index.dense[lastIndex] = entity; + index.aliveCount--; +}; + +/** + * Checks if an entity ID is currently alive in the index. + * @param index - The EntityIndex to check. + * @param entity - The packed entity to check. + * @returns True if the entity is alive, false otherwise. + */ +export const isEntityAlive = /* @inline @pure */ (index: EntityIndex, entity: Entity): boolean => { + const denseIndex = index.sparse[getEntityId(entity)]; + if (denseIndex === undefined || denseIndex >= index.aliveCount) return false; + const storedEntity = index.dense[denseIndex]; + return ( + getEntityGeneration(entity) === getEntityGeneration(storedEntity) && + getEntityWorldId(entity) === index.worldId + ); +}; + +/** + * Gets an array of all currently alive entities. + * @param index - The EntityIndex to get alive entities from. + * @returns An array of alive entities. + */ +export const getAliveEntities = (index: EntityIndex): Entity[] => { + return index.dense.slice(0, index.aliveCount); +}; diff --git a/packages/koota/src/entity/utils/pack-entity.ts b/packages/koota/src/entity/utils/pack-entity.ts new file mode 100644 index 00000000..d19c2435 --- /dev/null +++ b/packages/koota/src/entity/utils/pack-entity.ts @@ -0,0 +1,45 @@ +import type { Entity } from '../types'; + +// Constants for bit sizes +export const WORLD_ID_BITS = 4; // 4 bits can represent 0-15 +export const GENERATION_BITS = 8; // 8 bits can represent 0-255 +export const ENTITY_ID_BITS = 20; // 20 bits can represent 0-1,048,575 +// Total bits used: 4 + 8 + 20 = 32 bits + +// Masks for extracting values +export const WORLD_ID_MASK = (1 << WORLD_ID_BITS) - 1; +export const GENERATION_MASK = (1 << GENERATION_BITS) - 1; +export const ENTITY_ID_MASK = (1 << ENTITY_ID_BITS) - 1; + +// Bit shifts for positioning each component +export const GENERATION_SHIFT = ENTITY_ID_BITS; +export const WORLD_ID_SHIFT = GENERATION_SHIFT + GENERATION_BITS; + +export function packEntity(worldId: number, generation: number, entityId: number): Entity { + return (((worldId & WORLD_ID_MASK) << WORLD_ID_SHIFT) | + ((generation & GENERATION_MASK) << GENERATION_SHIFT) | + (entityId & ENTITY_ID_MASK)) as Entity; +} + +export function unpackEntity(entity: Entity) { + return { + worldId: entity >>> WORLD_ID_SHIFT, + generation: (entity >>> GENERATION_SHIFT) & GENERATION_MASK, + entityId: entity & ENTITY_ID_MASK, + }; +} + +export const getEntityId = /* @inline @pure */ (entity: Entity) => entity & ENTITY_ID_MASK; +export const getEntityWorldId = /* @inline @pure */ (entity: Entity) => entity >>> WORLD_ID_SHIFT; +export const getEntityAndWorldId = /* @pure */ (entity: Entity): [number, number] => [ + entity & ENTITY_ID_MASK, + entity >>> WORLD_ID_SHIFT, +]; + +export const getEntityGeneration = /* @inline @pure */ (entity: Entity) => + (entity >>> GENERATION_SHIFT) & GENERATION_MASK; + +export const incrementGeneration = (entity: Entity): Entity => + ((entity & ~(GENERATION_MASK << GENERATION_SHIFT)) | // Clear current generation bits + (((((entity >>> GENERATION_SHIFT) & GENERATION_MASK) + 1) & GENERATION_MASK) << + GENERATION_SHIFT)) as unknown as Entity; // Extract generation, increment, wrap around, shift back, and combine diff --git a/packages/koota/src/index.ts b/packages/koota/src/index.ts new file mode 100644 index 00000000..85ad338d --- /dev/null +++ b/packages/koota/src/index.ts @@ -0,0 +1,82 @@ +export { createActions } from './actions/create-actions'; +export type { Actions, ActionsInitializer, ActionRecord } from './actions/types'; +export { $internal } from './common'; +export type { Entity } from './entity/types'; +export { unpackEntity } from './entity/utils/pack-entity'; +export { shallowEqual } from './utils/shallow-equal'; +export { createAdded } from './query/modifiers/added'; +export { createChanged } from './query/modifiers/changed'; +export { Not } from './query/modifiers/not'; +export { Or } from './query/modifiers/or'; +export { createRemoved } from './query/modifiers/removed'; +export { $modifier } from './query/modifier'; +export { createQuery, IsExcluded } from './query/query'; +export type { + EventType, + InstancesFromParameters, + IsNotModifier, + Modifier, + Query, + QueryModifier, + QueryParameter, + QueryResult, + QueryResultOptions, + QuerySubscriber, + QueryUnsubscriber, + QueryHash, + SortableKeys, + SortDirection, + StoresFromParameters, +} from './query/types'; +export { $queryRef } from './query/symbols'; +export { relation } from './relation/relation'; +/** @experimental This API is experimental and may change or be removed in future versions. */ +export { ordered } from './relation/ordered'; +/** @experimental This API is experimental and may change or be removed in future versions. */ +export { OrderedList } from './relation/ordered-list'; +export { $relationPair, $relation } from './relation/symbols'; +export type { + /** @experimental This type is experimental and may change or be removed in future versions. */ + OrderedRelation as OrderedTrait, + RelationInputTarget, + Relation, + RelationPair, + RelationTarget, +} from './relation/types'; +export { getStore, trait } from './trait/trait'; +export type { + ConfigurableTrait, + ExtractIsTag, + ExtractSchema, + ExtractStore, + IsTag, + SetTraitCallback, + TagTrait, + Trait, + TraitRecord, + TraitTuple, + TraitValue, +} from './trait/types'; +export type { AoSFactory, Norm, Schema, Store, StoreType } from './storage/types'; +export type { TraitType } from './trait/types'; +export { universe } from './universe/universe'; +export type { World, WorldOptions } from './world'; +export { createWorld } from './world'; + +/** + * Deprecations. To be removed in v0.7.0. + */ + +import { createQuery } from './query/query'; +/** @deprecated Use createQuery instead */ +export const cacheQuery = createQuery; + +import type { TraitInstance } from './trait/types'; +/** @deprecated Use TraitInstance instead */ +export type TraitData = TraitInstance; + +/** @deprecated Will remove this internal type entirely */ +export type { TraitInstance } from './trait/types'; + +/** @deprecated Will remove this internal type entirely */ +export type { QueryInstance } from './query/types'; diff --git a/packages/koota/src/query/modifier.ts b/packages/koota/src/query/modifier.ts new file mode 100644 index 00000000..851ec1d4 --- /dev/null +++ b/packages/koota/src/query/modifier.ts @@ -0,0 +1,43 @@ +import type { Brand } from '../common'; +import type { Trait } from '../trait/types'; +import type { EventType, Modifier, OrModifier, QueryParameter } from './types'; + +export const $modifier = Symbol('modifier'); + +export function createModifier( + type: TType, + id: number, + traits: TTrait +): Modifier { + return { + [$modifier]: true, + type, + id, + traits, + traitIds: traits.map((trait) => trait.id), + } as const; +} + +export /* @inline @pure */ function isModifier(param: QueryParameter): param is Modifier { + return (param as Brand | null | undefined)?.[$modifier] as unknown as boolean; +} + +/** Check if a modifier is a tracking modifier (added, removed, or changed) */ +export function isTrackingModifier(modifier: Modifier): boolean { + const { type } = modifier; + return type.includes('added') || type.includes('removed') || type.includes('changed'); +} + +/** Get the tracking type from a modifier */ +export function getTrackingType(modifier: Modifier): EventType | null { + const { type } = modifier; + if (type.includes('added')) return 'add'; + if (type.includes('removed')) return 'remove'; + if (type.includes('changed')) return 'change'; + return null; +} + +/** Check if an Or modifier has nested modifiers */ +export function isOrWithModifiers(modifier: Modifier): modifier is OrModifier { + return modifier.type === 'or' && Array.isArray((modifier as OrModifier).modifiers); +} diff --git a/packages/koota/src/query/modifiers/added.ts b/packages/koota/src/query/modifiers/added.ts new file mode 100644 index 00000000..049fc66c --- /dev/null +++ b/packages/koota/src/query/modifiers/added.ts @@ -0,0 +1,25 @@ +import { $internal } from '../../common'; +import { isRelation } from '../../relation/utils/is-relation'; +import type { ExtractTraits, TraitOrRelation } from '../../trait/types'; +import { universe } from '../../universe/universe'; +import { createModifier } from '../modifier'; +import type { Modifier } from '../types'; +import { createTrackingId, setTrackingMasks } from '../utils/tracking-cursor'; + +export function createAdded() { + const id = createTrackingId(); + + for (const world of universe.worlds) { + if (!world) continue; + setTrackingMasks(world, id); + } + + return ( + ...inputs: T + ): Modifier, `added-${number}`> => { + const traits = inputs.map((input) => + isRelation(input) ? input[$internal].trait : input + ) as ExtractTraits; + return createModifier(`added-${id}`, id, traits); + }; +} diff --git a/packages/koota/src/query/modifiers/changed.ts b/packages/koota/src/query/modifiers/changed.ts new file mode 100644 index 00000000..ebd0d340 --- /dev/null +++ b/packages/koota/src/query/modifiers/changed.ts @@ -0,0 +1,80 @@ +import { $internal } from '../../common'; +import type { Entity } from '../../entity/types'; +import { getEntityId } from '../../entity/utils/pack-entity'; +import { isRelation } from '../../relation/utils/is-relation'; +import { hasTrait, registerTrait } from '../../trait/trait'; +import { getTraitInstance, hasTraitInstance } from '../../trait/trait-instance'; +import type { ExtractTraits, Trait, TraitOrRelation } from '../../trait/types'; +import { universe } from '../../universe/universe'; +import type { World } from '../../world'; +import { createModifier } from '../modifier'; +import type { Modifier } from '../types'; +import { checkQueryTrackingWithRelations } from '../utils/check-query-tracking-with-relations'; +import { createTrackingId, setTrackingMasks } from '../utils/tracking-cursor'; + +export function createChanged() { + const id = createTrackingId(); + + for (const world of universe.worlds) { + if (!world) continue; + setTrackingMasks(world, id); + } + + return ( + ...inputs: T + ): Modifier, `changed-${number}`> => { + const traits = inputs.map((input) => + isRelation(input) ? input[$internal].trait : input + ) as ExtractTraits; + return createModifier(`changed-${id}`, id, traits); + }; +} + +/** @inline */ +function markChanged(world: World, entity: Entity, trait: Trait) { + const ctx = world[$internal]; + + // Early exit if the trait is not on the entity. + if (!hasTrait(world, entity, trait)) return; + + // Register the trait if it's not already registered. + if (!hasTraitInstance(ctx.traitInstances, trait)) registerTrait(world, trait); + const data = getTraitInstance(ctx.traitInstances, trait)!; + + // Mark the trait as changed in bitmasks for Changed modifiers. + const eid = getEntityId(entity); + const { generationId, bitflag } = data; + + for (const changedMask of ctx.changedMasks.values()) { + if (!changedMask[generationId]) changedMask[generationId] = []; + if (!changedMask[generationId][eid]) changedMask[generationId][eid] = 0; + changedMask[generationId][eid] |= bitflag; + } + + // Update tracking queries with change event + for (const query of data.trackingQueries) { + if (!query.hasChangedModifiers) continue; + if (!query.changedTraits.has(trait)) continue; + + const match = + query.relationFilters && query.relationFilters.length > 0 + ? checkQueryTrackingWithRelations(world, query, entity, 'change', generationId, bitflag) + : query.checkTracking(world, entity, 'change', generationId, bitflag); + if (match) query.add(entity); + else query.remove(world, entity); + } + + return data; +} + +export function setChanged(world: World, entity: Entity, trait: Trait) { + const data = markChanged(world, entity, trait); + if (!data) return; + for (const sub of data.changeSubscriptions) sub(entity); +} + +export function setPairChanged(world: World, entity: Entity, trait: Trait, target: Entity) { + const data = markChanged(world, entity, trait); + if (!data) return; + for (const sub of data.changeSubscriptions) sub(entity, target); +} diff --git a/packages/koota/src/query/modifiers/not.ts b/packages/koota/src/query/modifiers/not.ts new file mode 100644 index 00000000..63be69ce --- /dev/null +++ b/packages/koota/src/query/modifiers/not.ts @@ -0,0 +1,7 @@ +import type { Trait } from '../../trait/types'; +import type { Modifier } from '../types'; +import { createModifier } from '../modifier'; + +export const Not = (...traits: T): Modifier => { + return createModifier('not', 1, traits); +}; diff --git a/packages/koota/src/query/modifiers/or.ts b/packages/koota/src/query/modifiers/or.ts new file mode 100644 index 00000000..3c159a8f --- /dev/null +++ b/packages/koota/src/query/modifiers/or.ts @@ -0,0 +1,22 @@ +import type { Trait } from '../../trait/types'; +import type { Modifier, OrModifier, OrParameter } from '../types'; +import { $modifier, createModifier } from '../modifier'; + +export const Or = (...params: T): OrModifier => { + // Separate traits from nested modifiers + const traits: Trait[] = []; + const modifiers: Modifier[] = []; + + for (const param of params) { + if ((param as Modifier)[$modifier]) { + modifiers.push(param as Modifier); + } else { + traits.push(param as Trait); + } + } + + const modifier = createModifier('or', 2, traits) as OrModifier; + modifier.modifiers = modifiers; + + return modifier; +}; diff --git a/packages/koota/src/query/modifiers/removed.ts b/packages/koota/src/query/modifiers/removed.ts new file mode 100644 index 00000000..d781ffb4 --- /dev/null +++ b/packages/koota/src/query/modifiers/removed.ts @@ -0,0 +1,25 @@ +import { $internal } from '../../common'; +import { isRelation } from '../../relation/utils/is-relation'; +import type { ExtractTraits, TraitOrRelation } from '../../trait/types'; +import { universe } from '../../universe/universe'; +import { createModifier } from '../modifier'; +import type { Modifier } from '../types'; +import { createTrackingId, setTrackingMasks } from '../utils/tracking-cursor'; + +export function createRemoved() { + const id = createTrackingId(); + + for (const world of universe.worlds) { + if (!world) continue; + setTrackingMasks(world, id); + } + + return ( + ...inputs: T + ): Modifier, `removed-${number}`> => { + const traits = inputs.map((input) => + isRelation(input) ? input[$internal].trait : input + ) as ExtractTraits; + return createModifier(`removed-${id}`, id, traits); + }; +} diff --git a/packages/koota/src/query/query-result.ts b/packages/koota/src/query/query-result.ts new file mode 100644 index 00000000..1527ec85 --- /dev/null +++ b/packages/koota/src/query/query-result.ts @@ -0,0 +1,415 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { isRelationPair } from '../relation/utils/is-relation'; +import type { Relation } from '../relation/types'; +import type { Store } from '../storage'; +import { getStore } from '../trait/trait'; +import type { Trait } from '../trait/types'; +import { shallowEqual } from '../utils/shallow-equal'; +import type { World } from '../world'; +import { isModifier } from './modifier'; +import { setChanged } from './modifiers/changed'; +import { getRelationSourceVersion } from '../relation/relation'; +import { getSortedResult, sortEntitiesInto } from './sort'; +import { $sortSource } from './symbols'; +import type { + InstancesFromParameters, + QueryInstance, + QueryParameter, + QueryResult, + QueryResultOptions, + RelationSortContext, + SortableKeys, + SortDirection, + StoresFromParameters, +} from './types'; +import { createQueryHash } from './utils/create-query-hash'; + +export function createQueryResult( + world: World, + entities: Entity[], + query: QueryInstance, + params: QueryParameter[], + sortKey?: string +): QueryResult { + const traits: Trait[] = []; + const stores: Store[] = []; + + getQueryStores(params, traits, stores, world); + + // `select` swaps the projected params, which changes the shape of a cached sorted result. + let currentParams = params; + const sortBaseKey = sortKey ?? `q${query.hash}`; + let sortSourceKey = sortBaseKey; + + const results = Object.assign(entities, { + readEach(callback: (state: InstancesFromParameters, entity: Entity, index: number) => void) { + const state = Array.from({ length: traits.length }) as InstancesFromParameters; + + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const eid = getEntityId(entity); + + // Create snapshots without atomic tracking + createSnapshots(eid, traits, stores, state); + + callback(state, entity, i); + } + + return results; + }, + + updateEach( + callback: (state: InstancesFromParameters, entity: Entity, index: number) => void, + options: QueryResultOptions = { changeDetection: 'auto' } + ) { + const state = Array.from({ length: traits.length }); + + // Inline all three permutations of updateEach for performance. + if (options.changeDetection === 'auto') { + const changedPairs: [Entity, Trait][] = []; + const atomicSnapshots: any[] = []; + const trackedIndices: number[] = []; + const untrackedIndices: number[] = []; + + getTrackedTraits(traits, world, query, trackedIndices, untrackedIndices); + + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const eid = getEntityId(entity); + + createSnapshotsWithAtomic(eid, traits, stores, state, atomicSnapshots); + callback(state as unknown as InstancesFromParameters, entity, i); + + // Skip if the entity has been destroyed. + if (!world.has(entity)) continue; + + // Commit all changes back to the stores for tracked traits. + for (let j = 0; j < trackedIndices.length; j++) { + const index = trackedIndices[j]; + const trait = traits[index]; + const ctx = trait[$internal]; + const newValue = state[index]; + const store = stores[index]; + + let changed = false; + if (ctx.type === 'aos') { + changed = ctx.fastSetWithChangeDetection(eid, store, newValue); + if (!changed) { + changed = !shallowEqual(newValue, atomicSnapshots[index]); + } + } else { + changed = ctx.fastSetWithChangeDetection(eid, store, newValue); + } + + // Collect changed traits. + if (changed) changedPairs.push([entity, trait] as const); + } + + // Commit all changes back to the stores for untracked traits. + for (let j = 0; j < untrackedIndices.length; j++) { + const index = untrackedIndices[j]; + const trait = traits[index]; + const ctx = trait[$internal]; + const store = stores[index]; + ctx.fastSet(eid, store, state[index]); + } + } + + // Trigger change events for each entity that was modified. + for (let i = 0; i < changedPairs.length; i++) { + const [entity, trait] = changedPairs[i]; + setChanged(world, entity, trait); + } + } else if (options.changeDetection === 'always') { + const changedPairs: [Entity, Trait][] = []; + const atomicSnapshots: any[] = []; + + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const eid = getEntityId(entity); + + createSnapshotsWithAtomic(eid, traits, stores, state, atomicSnapshots); + callback(state as unknown as InstancesFromParameters, entity, i); + + // Skip if the entity has been destroyed. + if (!world.has(entity)) continue; + + // Commit all changes back to the stores. + for (let j = 0; j < traits.length; j++) { + const trait = traits[j]; + const ctx = trait[$internal]; + const newValue = state[j]; + + let changed = false; + if (ctx.type === 'aos') { + changed = ctx.fastSetWithChangeDetection(eid, stores[j], newValue); + if (!changed) { + changed = !shallowEqual(newValue, atomicSnapshots[j]); + } + } else { + changed = ctx.fastSetWithChangeDetection(eid, stores[j], newValue); + } + + // Collect changed traits. + if (changed) changedPairs.push([entity, trait] as const); + } + } + + // Trigger change events for each entity that was modified. + for (let i = 0; i < changedPairs.length; i++) { + const [entity, trait] = changedPairs[i]; + setChanged(world, entity, trait); + } + } else if (options.changeDetection === 'never') { + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const eid = getEntityId(entity); + createSnapshots(eid, traits, stores, state); + callback(state as unknown as InstancesFromParameters, entity, i); + + // Skip if the entity has been destroyed. + if (!world.has(entity)) continue; + + // Commit all changes back to the stores. + for (let j = 0; j < traits.length; j++) { + const trait = traits[j]; + const ctx = trait[$internal]; + ctx.fastSet(eid, stores[j], state[j]); + } + } + } + + return results; + }, + + useStores(callback: (stores: StoresFromParameters, entities: readonly Entity[]) => void) { + callback(stores as unknown as StoresFromParameters, entities); + return results; + }, + + select(...params: U): QueryResult { + traits.length = 0; + stores.length = 0; + getQueryStores(params, traits, stores, world); + currentParams = params; + sortSourceKey = `${sortBaseKey}#${createQueryHash(params)}`; + return results as unknown as QueryResult; + }, + + sort( + callback: (a: Entity, b: Entity) => number = (a, b) => getEntityId(a) - getEntityId(b) + ): QueryResult { + Array.prototype.sort.call(entities, callback); + return results; + }, + + sortBy( + trait: S, + key: SortableKeys, + direction: SortDirection = 'asc' + ): QueryResult { + // Tracking queries rebuild their result on every run, so there is no stable + // entity set to cache an order against. Sort in place instead. + if (query.isTracking) { + sortEntitiesInto(world, entities, entities, trait, key as string, direction); + return results; + } + + return getSortedResult( + world, + { + key: sortSourceKey, + version: () => query.version, + createResult: (order, cacheKey) => + createQueryResult(world, order, query, currentParams, cacheKey), + }, + results, + entities, + trait, + key as string, + direction + ); + }, + }); + + return results; +} + +/* @inline */ function getTrackedTraits( + traits: Trait[], + world: World, + query: QueryInstance, + trackedIndices: number[], + untrackedIndices: number[] +) { + for (let i = 0; i < traits.length; i++) { + const trait = traits[i]; + const hasTracked = world[$internal].trackedTraits.has(trait); + const hasChanged = query.hasChangedModifiers && query.changedTraits.has(trait); + + if (hasTracked || hasChanged) trackedIndices.push(i); + else untrackedIndices.push(i); + } +} + +/* @inline */ function createSnapshots( + entityId: number, + traits: Trait[], + stores: Store[], + state: any[] +) { + for (let i = 0; i < traits.length; i++) { + const trait = traits[i]; + const ctx = trait[$internal]; + const value = ctx.get(entityId, stores[i]); + state[i] = value; + } +} + +/* @inline */ function createSnapshotsWithAtomic( + entityId: number, + traits: Trait[], + stores: Store[], + state: any[], + atomicSnapshots: any[] +) { + for (let j = 0; j < traits.length; j++) { + const trait = traits[j]; + const ctx = trait[$internal]; + const value = ctx.get(entityId, stores[j]); + state[j] = value; + atomicSnapshots[j] = ctx.type === 'aos' ? { ...value } : null; + } +} + +/* @inline */ export function getQueryStores( + params: T, + traits: Trait[], + stores: Store[], + world: World +) { + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + // Handle relation pairs + if (isRelationPair(param)) { + const relation = param.relation as Relation; + const baseTrait = relation[$internal].trait; + if (baseTrait[$internal].type !== 'tag') { + traits.push(baseTrait); + stores.push(getStore(world, baseTrait)); + } + continue; + } + + if (isModifier(param)) { + // Skip not modifier. + if (param.type === 'not') continue; + + const modifierTraits = param.traits; + for (const trait of modifierTraits) { + if (trait[$internal].type === 'tag') continue; // Skip tags + traits.push(trait); + stores.push(getStore(world, trait)); + } + } else { + const trait = param as Trait; + if (trait[$internal].type === 'tag') continue; // Skip tags + traits.push(trait); + stores.push(getStore(world, trait)); + } + } +} + +export function createEmptyQueryResult(): QueryResult { + const results = Object.assign([], { + readEach: () => results, + updateEach: () => results, + useStores: () => results, + select: () => results, + sort: () => results, + sortBy: () => results, + }) as QueryResult; + + return results; +} + +// Shared methods for relation-only query snapshots. +const relationOnlyMethods = { + readEach(this: QueryResult, callback: any) { + for (let i = 0; i < this.length; i++) { + callback([], this[i], i); + } + return this; + }, + updateEach(this: QueryResult, callback: any) { + for (let i = 0; i < this.length; i++) { + callback([], this[i], i); + } + return this; + }, + useStores(this: QueryResult, callback: any) { + callback([], this); + return this; + }, + select(this: QueryResult) { + return this; + }, + sort( + this: QueryResult, + callback: (a: Entity, b: Entity) => number = (a, b) => getEntityId(a) - getEntityId(b) + ) { + Array.prototype.sort.call(this, callback); + return this; + }, + sortBy( + this: QueryResult, + trait: Trait, + key: string, + direction: SortDirection = 'asc' + ): QueryResult { + const source = this[$sortSource]; + if (!source) return this; + + const { world, relationTrait, target } = source; + const relation = relationTrait[$internal].relation as Relation; + + return getSortedResult( + world, + { + key: source.sortKey ?? `r${relationTrait.id}:${getEntityId(target)}`, + version: () => getRelationSourceVersion(world, relation, target), + createResult: (order, cacheKey) => + createRelationOnlyQueryResult(order, { ...source, sortKey: cacheKey }), + }, + this, + this, + trait, + key, + direction + ); + }, +}; + +/** + * Lightweight query result for relation-only queries. + * Skips store/trait setup since we only need to iterate entities. + * + * `sortSource` identifies the source set and is only needed to support `sortBy`; without + * it `sortBy` returns the result unchanged. + */ +export function createRelationOnlyQueryResult( + entities: Entity[], + sortSource?: RelationSortContext +): QueryResult { + if (entities.length === 0) return cachedEmptyRelationResult as unknown as QueryResult; + + const result = Object.assign(entities, relationOnlyMethods) as unknown as QueryResult; + if (sortSource !== undefined) (result as any)[$sortSource] = sortSource; + + return result; +} + +const cachedEmptyRelationResult = Object.assign([], relationOnlyMethods) as QueryResult; diff --git a/packages/koota/src/query/query.ts b/packages/koota/src/query/query.ts new file mode 100644 index 00000000..e8a7a3cb --- /dev/null +++ b/packages/koota/src/query/query.ts @@ -0,0 +1,511 @@ +import { SparseSet } from '../collections'; +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { getEntitiesWithRelationTo, hasRelationPair } from '../relation/relation'; +import type { Relation } from '../relation/types'; +import { isRelationPair } from '../relation/utils/is-relation'; +import { registerTrait, trait } from '../trait/trait'; +import { getTraitInstance, hasTraitInstance } from '../trait/trait-instance'; +import type { TagTrait, Trait } from '../trait/types'; +import { universe } from '../universe/universe'; +import type { World } from '../world'; +import { getTrackingType, isModifier, isOrWithModifiers, isTrackingModifier } from './modifier'; +import { createQueryResult } from './query-result'; +import { $queryRef } from './symbols'; +import { + type EventType, + type Modifier, + type Query, + type QueryInstance, + type QueryParameter, + type QueryResult, + type ResolvedRelationFilter, + type QuerySubscriber, + type TrackingGroup, +} from './types'; +import { checkQuery } from './utils/check-query'; +import { checkQueryTracking } from './utils/check-query-tracking'; +import { checkQueryWithRelations } from './utils/check-query-with-relations'; +import { createQueryHash } from './utils/create-query-hash'; +import { isQuery } from './utils/is-query'; + +export const IsExcluded: TagTrait = trait(); + +function resolveRelationFilter(filter: ResolvedRelationFilter): ResolvedRelationFilter { + if (!filter.targetQuery) return filter; + + const targetQueryRef = isQuery(filter.targetQuery) + ? filter.targetQuery + : createQuery(...filter.targetQuery); + + return { + ...filter, + targetQueryRef, + targetQueryMatches: new SparseSet(), + }; +} + +export function runQuery( + world: World, + query: QueryInstance, + params: QueryParameter[] +): QueryResult { + commitQueryRemovals(world); + + // With hybrid bitmask strategy, query.entities is already incrementally maintained + // with both trait and relation filters applied. Just return the pre-filtered entities. + const entities = query.entities.dense.slice() as Entity[]; + + // Clear so it can accumulate again. + if (query.isTracking) { + query.entities.clear(); + // PERF: Use indexed loop instead of for...of + const len = entities.length; + for (let i = 0; i < len; i++) { + query.resetTrackingBitmasks(entities[i]); + } + } + + return createQueryResult(world, entities, query, params); +} + +export function addEntityToQuery(query: QueryInstance, entity: Entity) { + query.toRemove.remove(entity); + query.entities.add(entity); + + // Notify subscriptions. + for (const sub of query.addSubscriptions) { + sub(entity); + } + + query.version++; +} + +export function removeEntityFromQuery(world: World, query: QueryInstance, entity: Entity) { + if (!query.entities.has(entity) || query.toRemove.has(entity)) return; + + const ctx = world[$internal]; + + query.toRemove.add(entity); + ctx.dirtyQueries.add(query); + + // Notify subscriptions. + for (const sub of query.removeSubscriptions) { + sub(entity); + } + + query.version++; +} + +export function commitQueryRemovals(world: World) { + const ctx = world[$internal]; + if (!ctx.dirtyQueries.size) return; + + for (const query of ctx.dirtyQueries) { + for (let i = query.toRemove.dense.length - 1; i >= 0; i--) { + const eid = query.toRemove.dense[i]; + query.toRemove.remove(eid); + query.entities.remove(eid); + } + } + + ctx.dirtyQueries.clear(); +} + +/** Reset tracking state for an entity across all tracking groups */ +export function resetQueryTrackingBitmasks(query: QueryInstance, eid: number) { + const groups = query.trackingGroups; + const len = groups.length; + for (let i = 0; i < len; i++) { + const trackers = groups[i].trackers; + const trackersLen = trackers.length; + for (let j = 0; j < trackersLen; j++) { + const tracker = trackers[j]; + if (tracker) tracker[eid] = 0; + } + } +} + +/** + * Unified function to process tracking modifiers with explicit AND/OR logic. + * Groups modifiers by (type, id, logic) key so same-tracker calls are combined. + */ +function processTrackingModifier( + world: World, + query: QueryInstance, + modifier: Modifier, + logic: 'and' | 'or', + ctx: World[typeof $internal], + groupsMap: Map +): void { + const trackingType = getTrackingType(modifier); + if (!trackingType) return; + + const id = modifier.id; + // Key includes logic so Changed(A) at top-level stays separate from Or(Changed(A)) + const key = `${trackingType}-${id}-${logic}`; + + // Find or create tracking group + let group = groupsMap.get(key); + if (!group) { + group = { + logic, + type: trackingType, + id, + bitmasks: [], + trackers: [], + }; + groupsMap.set(key, group); + query.trackingGroups.push(group); + } + + // Register traits and build bitmasks + for (const trait of modifier.traits) { + if (!hasTraitInstance(ctx.traitInstances, trait)) registerTrait(world, trait); + const instance = getTraitInstance(ctx.traitInstances, trait)!; + query.traits.push(trait); + + // Add to traitInstances.all for query registration + query.traitInstances.all.push(instance); + + // Build bitmasks by generation + const genId = instance.generationId; + group.bitmasks[genId] = (group.bitmasks[genId] || 0) | instance.bitflag; + + // Track changed traits for change detection in query-result + if (trackingType === 'change') { + query.changedTraits.add(trait); + query.hasChangedModifiers = true; + } + } + + query.isTracking = true; +} + +export function createQueryInstance( + world: World, + parameters: T +): QueryInstance { + const query: QueryInstance = { + version: 0, + world, + parameters, + hash: '', + traits: [], + traitInstances: { + required: [], + forbidden: [], + or: [], + all: [], + }, + staticBitmasks: [], + trackingGroups: [], + generations: [], + entities: new SparseSet(), + isTracking: false, + hasChangedModifiers: false, + changedTraits: new Set(), + toRemove: new SparseSet(), + cleanup: [], + addSubscriptions: new Set(), + removeSubscriptions: new Set(), + relationFilters: [], + + run: (world: World, params: QueryParameter[]) => runQuery(world, query, params), + add: (entity: Entity) => addEntityToQuery(query, entity), + remove: (world: World, entity: Entity) => removeEntityFromQuery(world, query, entity), + check: (world: World, entity: Entity) => checkQuery(world, query, entity), + checkTracking: ( + world: World, + entity: Entity, + eventType: EventType, + generationId: number, + bitflag: number + ) => checkQueryTracking(world, query, entity, eventType, generationId, bitflag), + resetTrackingBitmasks: (eid: number) => resetQueryTrackingBitmasks(query, eid), + }; + + const ctx = world[$internal]; + + // Map for grouping tracking modifiers by (type, id, logic) + const trackingGroupsMap = new Map(); + + // Process all parameters + for (let i = 0; i < parameters.length; i++) { + const parameter = parameters[i]; + + // Handle relation pairs + if (isRelationPair(parameter)) { + const relation = parameter.relation; + query.relationFilters!.push(resolveRelationFilter(parameter)); + + const baseTrait = (relation as Relation)[$internal].trait; + if (!hasTraitInstance(ctx.traitInstances, baseTrait)) registerTrait(world, baseTrait); + query.traitInstances.required.push(getTraitInstance(ctx.traitInstances, baseTrait)!); + query.traits.push(baseTrait); + + continue; + } + + if (isModifier(parameter)) { + const traits = parameter.traits; + + // Register traits + for (let j = 0; j < traits.length; j++) { + const t = traits[j]; + if (!hasTraitInstance(ctx.traitInstances, t)) registerTrait(world, t); + } + + if (parameter.type === 'not') { + query.traitInstances.forbidden.push( + ...traits.map((t) => getTraitInstance(ctx.traitInstances, t)!) + ); + } else if (parameter.type === 'or') { + // Handle regular traits in Or + query.traitInstances.or.push(...traits.map((t) => getTraitInstance(ctx.traitInstances, t)!)); + + // Handle nested tracking modifiers in Or + if (isOrWithModifiers(parameter)) { + for (const nestedModifier of parameter.modifiers) { + if (isTrackingModifier(nestedModifier)) { + processTrackingModifier(world, query, nestedModifier, 'or', ctx, trackingGroupsMap); + } + } + } + } else if (isTrackingModifier(parameter)) { + // Top-level tracking modifiers use AND logic + processTrackingModifier(world, query, parameter, 'and', ctx, trackingGroupsMap); + } + } else { + // Regular trait + const t = parameter as Trait; + if (!hasTraitInstance(ctx.traitInstances, t)) registerTrait(world, t); + query.traitInstances.required.push(getTraitInstance(ctx.traitInstances, t)!); + query.traits.push(t); + } + } + + // Add IsExcluded to the forbidden list + query.traitInstances.forbidden.push(getTraitInstance(ctx.traitInstances, IsExcluded)!); + + // Build traitInstances.all from static instances (tracking instances already added by processTrackingModifier) + query.traitInstances.all = [ + ...query.traitInstances.all, // Tracking instances added by processTrackingModifier + ...query.traitInstances.required, + ...query.traitInstances.forbidden, + ...query.traitInstances.or, + ]; + + // Create an array of all trait generations + query.generations = query.traitInstances.all + .map((c) => c.generationId) + .reduce((a: number[], v) => { + if (a.includes(v)) return a; + a.push(v); + return a; + }, []); + + // Create static bitmasks (required/forbidden/or only - tracking is in trackingGroups) + query.staticBitmasks = query.generations.map((generationId) => { + const required = query.traitInstances.required + .filter((c) => c.generationId === generationId) + .reduce((a, c) => a | c.bitflag, 0); + + const forbidden = query.traitInstances.forbidden + .filter((c) => c.generationId === generationId) + .reduce((a, c) => a | c.bitflag, 0); + + const or = query.traitInstances.or + .filter((c) => c.generationId === generationId) + .reduce((a, c) => a | c.bitflag, 0); + + return { required, forbidden, or }; + }); + + // Create hash + query.hash = createQueryHash(parameters); + + // Add to world + ctx.queriesHashMap.set(query.hash, query); + + // Register query with trait instances + if (query.isTracking) { + query.traitInstances.all.forEach((instance) => { + instance.trackingQueries.add(query); + }); + } else { + query.traitInstances.all.forEach((instance) => { + instance.queries.add(query); + }); + } + + // Add to notQueries if has forbidden traits + if (query.traitInstances.forbidden.length > 0) ctx.notQueries.add(query); + + // Index queries with relation filters + const hasRelationFilters = query.relationFilters && query.relationFilters.length > 0; + + if (hasRelationFilters) { + for (const pair of query.relationFilters!) { + const relationTrait = pair.relation[$internal].trait; + const relationTraitInstance = getTraitInstance(ctx.traitInstances, relationTrait); + if (relationTraitInstance) { + relationTraitInstance.relationQueries.add(query); + } + + if (pair.targetQueryRef && pair.targetQueryMatches) { + const matchingTargets = world.query(pair.targetQueryRef); + for (let i = 0; i < matchingTargets.length; i++) { + pair.targetQueryMatches.add(matchingTargets[i]); + } + + const refreshSourcesForTarget = (target: Entity) => { + const sources = getEntitiesWithRelationTo(world, pair.relation as Relation, target); + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const match = checkQueryWithRelations(world, query, source); + if (match) { + query.add(source); + } else { + query.remove(world, source); + } + } + }; + + query.cleanup.push( + world.onQueryAdd(pair.targetQueryRef, (target) => { + pair.targetQueryMatches!.add(target); + refreshSourcesForTarget(target); + }) + ); + query.cleanup.push( + world.onQueryRemove(pair.targetQueryRef, (target) => { + pair.targetQueryMatches!.remove(target); + refreshSourcesForTarget(target); + }) + ); + } + } + } + + // Populate query with initial matching entities + if (query.trackingGroups.length > 0) { + // For tracking queries, check each entity against tracking groups + for (const group of query.trackingGroups) { + const { type, id, logic, bitmasks } = group; + const snapshot = ctx.trackingSnapshots.get(id)!; + const dirtyMask = ctx.dirtyMasks.get(id)!; + const changedMask = ctx.changedMasks.get(id)!; + + for (const entity of ctx.entityIndex.dense) { + // For AND groups, skip if already in query (will be checked by other groups) + // For OR groups, skip if already in query + if (query.entities.has(entity)) continue; + + const eid = getEntityId(entity); + let matches = logic === 'and'; // AND starts true, OR starts false + + // Check each generation that has bitmasks + for (let genId = 0; genId < bitmasks.length; genId++) { + const mask = bitmasks[genId]; + if (!mask) continue; + + const oldMask = snapshot[genId]?.[eid] || 0; + const currentMask = ctx.entityMasks[genId]?.[eid] || 0; + + // Check each bit in the mask + for (let bit = 1; bit <= mask; bit <<= 1) { + if (!(mask & bit)) continue; + + let traitMatches = false; + + switch (type) { + case 'add': + traitMatches = (oldMask & bit) === 0 && (currentMask & bit) === bit; + break; + case 'remove': + traitMatches = + ((oldMask & bit) === bit && (currentMask & bit) === 0) || + ((oldMask & bit) === 0 && + (currentMask & bit) === 0 && + ((dirtyMask[genId]?.[eid] ?? 0) & bit) === bit); + break; + case 'change': + traitMatches = ((changedMask[genId]?.[eid] ?? 0) & bit) === bit; + break; + } + + if (logic === 'and') { + if (!traitMatches) { + matches = false; + break; + } + } else { + // OR logic + if (traitMatches) { + matches = true; + break; + } + } + } + + // Early exit for AND that failed or OR that succeeded + if (logic === 'and' && !matches) break; + if (logic === 'or' && matches) break; + } + + if (matches) { + if (hasRelationFilters) { + let relationMatch = true; + for (const pair of query.relationFilters!) { + if (!hasRelationPair(world, entity, pair)) { + relationMatch = false; + break; + } + } + if (relationMatch) query.add(entity); + } else { + query.add(entity); + } + } + } + } + } else { + // Non-tracking query: populate immediately + const entities = ctx.entityIndex.dense; + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const match = hasRelationFilters + ? checkQueryWithRelations(world, query, entity) + : query.check(world, entity); + if (match) query.add(entity); + } + } + + return query; +} + +let queryId = 0; + +export function createQuery(...parameters: T): Query { + const hash = createQueryHash(parameters); + + // Check if this query was already cached + const existing = universe.cachedQueries.get(hash); + if (existing) return existing as Query; + + // Create new query ref with ID + const id = queryId++; + const queryRef = Object.freeze({ + [$queryRef]: true, + id, + hash, + parameters, + }) as Query; + + // Cache the ref for deduplication and stable IDs + universe.cachedQueries.set(hash, queryRef); + + return queryRef; +} diff --git a/packages/koota/src/query/sort.ts b/packages/koota/src/query/sort.ts new file mode 100644 index 00000000..6571e88a --- /dev/null +++ b/packages/koota/src/query/sort.ts @@ -0,0 +1,169 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { registerTrait } from '../trait/trait'; +import { getTraitInstance, hasTraitInstance } from '../trait/trait-instance'; +import type { Trait } from '../trait/types'; +import type { World } from '../world'; +import type { QueryParameter, QueryResult, SortCache, SortDirection, SortSource } from './types'; + +/** Values that have no meaningful position and are always pushed to the end. */ +/* @inline @pure */ function isMissing(value: unknown): boolean { + // `value !== value` catches NaN. + return value === undefined || value === null || value !== value; +} + +/* @inline @pure */ function compareValues(a: any, b: any): number { + if (typeof a === 'number' && typeof b === 'number') return a < b ? -1 : a > b ? 1 : 0; + if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0; + if (typeof a === 'boolean' && typeof b === 'boolean') return a === b ? 0 : a ? 1 : -1; + + // Mixed types have no natural order, so group them by type name to stay deterministic. + const typeA = typeof a; + const typeB = typeof b; + if (typeA !== typeB) return typeA < typeB ? -1 : 1; + + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Reads the sort value of every entity once so the comparator never touches the store. */ +function readSortValues( + world: World, + entities: readonly Entity[], + trait: Trait, + key: string, + values: unknown[] +) { + const ctx = world[$internal]; + if (!hasTraitInstance(ctx.traitInstances, trait)) registerTrait(world, trait); + + const instance = getTraitInstance(ctx.traitInstances, trait)!; + const store = instance.store as any; + const isAoS = trait[$internal].type === 'aos'; + const column = isAoS ? undefined : store[key]; + + // Stores keep stale values after a trait is removed and entity ids are recycled, so + // presence has to come from the bitmask rather than from the value itself. + const { generationId, bitflag } = instance; + const masks = ctx.entityMasks[generationId]; + + for (let i = 0; i < entities.length; i++) { + const eid = getEntityId(entities[i]); + if ((masks[eid] & bitflag) !== bitflag) { + values[i] = undefined; + continue; + } + values[i] = isAoS ? store[eid]?.[key] : column?.[eid]; + } +} + +/** + * Sorts `source` by `trait[key]` and writes the result into `target`. + * `target` may be `source`. The sort is stable: entities that compare equal + * keep their relative order from `source`. + */ +export function sortEntitiesInto( + world: World, + source: readonly Entity[], + target: Entity[], + trait: Trait, + key: string, + direction: SortDirection +) { + const length = source.length; + const values = new Array(length); + readSortValues(world, source, trait, key, values); + + const indices = new Array(length); + for (let i = 0; i < length; i++) indices[i] = i; + + const sign = direction === 'desc' ? -1 : 1; + + indices.sort((indexA, indexB) => { + const a = values[indexA]; + const b = values[indexB]; + + // Missing values sink to the bottom in both directions. + const missingA = isMissing(a); + const missingB = isMissing(b); + if (missingA || missingB) { + if (missingA && missingB) return indexA - indexB; + return missingA ? 1 : -1; + } + + const delta = compareValues(a, b); + return delta !== 0 ? sign * delta : indexA - indexB; + }); + + // Reading from `target` while writing to it would corrupt the permutation. + const from = target === source ? source.slice() : source; + + target.length = length; + for (let i = 0; i < length; i++) target[i] = from[indices[i]]; +} + +/** + * Returns a query result ordered by `trait[key]`, reusing a cached order when neither + * the source entity set nor any sort value has changed since the last call. + * + * The cached order is stored per (source, trait, key, direction) on the world, so a + * cache hit is a version comparison and returns the same result object as last time. + */ +export function getSortedResult( + world: World, + source: SortSource, + fallback: QueryResult, + entities: readonly Entity[], + trait: Trait, + key: string, + direction: SortDirection +): QueryResult { + // Fewer than two entities are trivially ordered — skip the cache entirely. + if (entities.length < 2) return fallback; + + const ctx = world[$internal]; + const cacheKey = `${source.key}|${trait.id}|${String(key)}|${direction}`; + const version = source.version(); + + let cache = ctx.sortCaches.get(cacheKey); + + // O(1) hit: the entity set and every sort value are untouched since the last sort. + if (cache && !cache.dirty && cache.version === version) return cache.result as QueryResult; + + if (!cache) { + // Fill the order before wrapping it — result factories may special-case an empty array. + const order: Entity[] = []; + sortEntitiesInto(world, entities, order, trait, key, direction); + + const entry: SortCache = { + order, + result: source.createResult(order, cacheKey), + version, + dirty: false, + members: new Set(), + }; + + // The entity set is versioned by the source, but writing a sort value leaves it + // untouched, so mark the order stale whenever the trait moves on a member entity. + const invalidate = (entity: Entity) => { + if (entry.members.has(entity)) entry.dirty = true; + }; + world.onChange(trait, invalidate); + world.onAdd(trait, invalidate); + world.onRemove(trait, invalidate); + + cache = entry; + ctx.sortCaches.set(cacheKey, cache); + } else { + // Mutate the cached array in place so the cached result object stays valid. + sortEntitiesInto(world, entities, cache.order, trait, key, direction); + } + + cache.members.clear(); + for (let i = 0; i < cache.order.length; i++) cache.members.add(cache.order[i]); + + cache.version = version; + cache.dirty = false; + + return cache.result as QueryResult; +} diff --git a/packages/koota/src/query/symbols.ts b/packages/koota/src/query/symbols.ts new file mode 100644 index 00000000..71462f37 --- /dev/null +++ b/packages/koota/src/query/symbols.ts @@ -0,0 +1,3 @@ +export const $parameters = Symbol.for('parameters'); +export const $queryRef = Symbol.for('queryRef'); +export const $sortSource = Symbol.for('sortSource'); diff --git a/packages/koota/src/query/types.ts b/packages/koota/src/query/types.ts new file mode 100644 index 00000000..8ffcd028 --- /dev/null +++ b/packages/koota/src/query/types.ts @@ -0,0 +1,248 @@ +import type { SparseSet } from '../collections'; +import type { Entity } from '../entity/types'; +import type { RelationPair } from '../relation/types'; +import type { AoSFactory } from '../storage'; +import type { + ExtractSchema, + ExtractStore, + IsTag, + Trait, + TraitInstance, + TraitRecord, +} from '../trait/types'; +import type { World } from '../world'; +import { $modifier } from './modifier'; +import { $parameters, $queryRef, $sortSource } from './symbols'; + +export type QueryModifier = (...components: Trait[]) => Modifier; +export type QueryParameter = Trait | RelationPair | ReturnType; +export type QuerySubscriber = (entity: Entity) => void; +export type QueryUnsubscriber = () => void; + +export type QueryResultOptions = { + changeDetection?: 'always' | 'auto' | 'never'; +}; + +/** Direction accepted by `QueryResult.sortBy`. */ +export type SortDirection = 'asc' | 'desc'; + +/** Trait values that define a natural order. */ +type SortableValue = string | number | bigint | boolean; + +/** Keys of a trait whose value can be used as a sort key. */ +export type SortableKeys = { + // The `[never]` guard keeps tag traits — whose record is `Record` — from + // reporting every string key as sortable, since `never extends SortableValue` is true. + [K in keyof TraitRecord]-?: [TraitRecord[K]] extends [never] + ? never + : NonNullable[K]> extends SortableValue + ? K + : never; +}[keyof TraitRecord]; + +/** Identity and invalidation hooks for the entity set feeding a `sortBy` cache. */ +export type SortSource = { + /** Stable identity of the entity set being sorted. */ + key: string; + /** Monotonic version of the entity set; changes whenever entities enter or leave it. */ + version(): number; + /** + * Builds the cached result wrapper around the (mutable) ordered array. `cacheKey` is the + * full identity of this ordering and becomes the source key of any further `sortBy` on + * it, so chained sorts do not collide with a direct sort of the same query. + */ + createResult(order: Entity[], cacheKey: string): QueryResult; +}; + +/** A cached ordering produced by `QueryResult.sortBy`. */ +export type SortCache = { + /** Ordered entities backing `result`. Mutated in place so `result` stays stable. */ + order: Entity[]; + /** Result wrapper handed back on a cache hit. */ + result: QueryResult; + /** Source-set version this order was built from. */ + version: number; + /** Set when a sort value changed on one of `members`. */ + dirty: boolean; + /** Entities currently in the order, used to filter trait events. */ + members: Set; +}; + +/** Context stashed on relation-only results so `sortBy` can find its cache. */ +export type RelationSortContext = { + world: World; + relationTrait: Trait; + target: Entity; + sortKey?: string; +}; + +export type QueryResult = readonly Entity[] & { + /** @internal Present on relation-only results to support `sortBy`. */ + [$sortSource]?: RelationSortContext; + readEach: ( + callback: (state: InstancesFromParameters, entity: Entity, index: number) => void + ) => QueryResult; + updateEach: ( + callback: (state: InstancesFromParameters, entity: Entity, index: number) => void, + options?: QueryResultOptions + ) => QueryResult; + useStores: ( + callback: (stores: StoresFromParameters, entities: readonly Entity[]) => void + ) => QueryResult; + select(...params: U): QueryResult; + sort(callback?: (a: Entity, b: Entity) => number): QueryResult; + sortBy( + trait: S, + key: SortableKeys, + direction?: SortDirection + ): QueryResult; +}; + +type UnwrapModifierData = T extends Modifier ? C : never; + +export type StoresFromParameters = T extends [infer First, ...infer Rest] + ? [ + ...(First extends Trait + ? [ExtractStore] + : First extends Modifier + ? StoresFromParameters> + : []), + ...(Rest extends QueryParameter[] ? StoresFromParameters : []), + ] + : []; + +export type InstancesFromParameters = T extends [ + infer First, + ...infer Rest, +] + ? [ + ...(First extends Trait + ? IsTag extends false + ? ExtractSchema extends AoSFactory + ? [ReturnType>] + : [TraitRecord] + : [] + : First extends Modifier + ? IsNotModifier extends true + ? [] + : InstancesFromParameters> + : []), + ...(Rest extends QueryParameter[] ? InstancesFromParameters : []), + ] + : []; + +export type IsNotModifier = + T extends Modifier ? (TType extends 'not' ? true : false) : false; + +export type QueryHash = string; + +export type Query = { + readonly [$queryRef]: true; + /** Public read-only ID for fast array lookups */ + readonly id: number; + /** Hash string for deduplication */ + readonly hash: QueryHash; + /** Query parameters for creating instances */ + readonly parameters: T; + readonly [$parameters]: T; +}; + +export type ResolvedRelationFilter = RelationPair & { + targetQueryRef?: Query; + targetQueryMatches?: SparseSet; +}; + +export type Modifier = { + [$modifier]: true; + type: TType; + id: number; + traits: TTrait; + traitIds: number[]; +}; + +/** Parameter types that can be passed to Or modifier */ +export type OrParameter = Trait | Modifier; + +/** Or modifier that can contain both traits and nested modifiers */ +export type OrModifier = Modifier< + ExtractTraitsFromOrParams, + 'or' +> & { + modifiers: Modifier[]; +}; + +/** Extract traits from Or parameters (filters out modifiers) */ +type ExtractTraitsFromOrParams = T extends [infer First, ...infer Rest] + ? First extends Trait + ? Rest extends OrParameter[] + ? [First, ...ExtractTraitsFromOrParams] + : [First] + : Rest extends OrParameter[] + ? ExtractTraitsFromOrParams + : [] + : []; + +/** + * Unified tracking group that supports both AND and OR logic. + * Replaces the old separate tracking arrays and OrTrackingGroup. + */ +export type TrackingGroup = { + /** Whether all traits must match (and) or any trait can match (or) */ + logic: 'and' | 'or'; + /** The type of tracking event */ + type: 'add' | 'remove' | 'change'; + /** Tracking modifier ID for snapshot/mask lookups */ + id: number; + /** Bitmasks indexed by generationId */ + bitmasks: (number | undefined)[]; + /** Per-entity tracker state indexed by [generationId][entityId] */ + trackers: (number[] | undefined)[]; +}; + +export type QueryInstance = { + version: number; + world: World; + parameters: T; + hash: QueryHash; + traits: Trait[]; + /** Static trait instances for non-tracking query matching */ + traitInstances: { + required: TraitInstance[]; + forbidden: TraitInstance[]; + or: TraitInstance[]; + all: TraitInstance[]; + }; + /** Static bitmasks for non-tracking query matching (indexed by generationId) */ + staticBitmasks: { + required: number; + forbidden: number; + or: number; + }[]; + /** Unified tracking groups with explicit AND/OR logic */ + trackingGroups: TrackingGroup[]; + generations: number[]; + entities: SparseSet; + isTracking: boolean; + hasChangedModifiers: boolean; + changedTraits: Set; + toRemove: SparseSet; + cleanup: QueryUnsubscriber[]; + addSubscriptions: Set; + removeSubscriptions: Set; + /** Relation pairs for target-specific queries */ + relationFilters?: ResolvedRelationFilter[]; + run: (world: World, params: QueryParameter[]) => QueryResult; + add: (entity: Entity) => void; + remove: (world: World, entity: Entity) => void; + check: (world: World, entity: Entity) => boolean; + checkTracking: ( + world: World, + entity: Entity, + eventType: 'add' | 'remove' | 'change', + generationId: number, + bitflag: number + ) => boolean; + resetTrackingBitmasks: (eid: number) => void; +}; + +export type EventType = 'add' | 'remove' | 'change'; diff --git a/packages/koota/src/query/utils/check-query-tracking-with-relations.ts b/packages/koota/src/query/utils/check-query-tracking-with-relations.ts new file mode 100644 index 00000000..5c00bf92 --- /dev/null +++ b/packages/koota/src/query/utils/check-query-tracking-with-relations.ts @@ -0,0 +1,42 @@ +import type { Entity } from '../../entity/types'; +import { hasRelationPair, hasRelationTargetInSet } from '../../relation/relation'; +import type { World } from '../../world'; +import type { EventType, QueryInstance } from '../types'; +import { checkQueryTracking } from './check-query-tracking'; + +/** + * Check if an entity matches a tracking query with relation filters. + * Combines checkQueryTracking (trait bitmasks + tracking state) with relation checks. + */ +export function checkQueryTrackingWithRelations( + world: World, + query: QueryInstance, + entity: Entity, + eventType: EventType, + eventGenerationId: number, + eventBitflag: number +): boolean { + // First check trait bitmasks and tracking state (fast) + if (!checkQueryTracking(world, query, entity, eventType, eventGenerationId, eventBitflag)) { + return false; + } + + // Then check relation pairs if any + if (query.relationFilters && query.relationFilters.length > 0) { + for (const pair of query.relationFilters) { + if (pair.targetQueryMatches) { + if (!hasRelationTargetInSet(world, pair.relation, entity, pair.targetQueryMatches)) { + return false; + } + + continue; + } + + if (!hasRelationPair(world, entity, pair)) { + return false; + } + } + } + + return true; +} diff --git a/packages/koota/src/query/utils/check-query-tracking.ts b/packages/koota/src/query/utils/check-query-tracking.ts new file mode 100644 index 00000000..2517cc1a --- /dev/null +++ b/packages/koota/src/query/utils/check-query-tracking.ts @@ -0,0 +1,157 @@ +import { $internal } from '../../common'; +import type { Entity } from '../../entity/types'; +import { getEntityId } from '../../entity/utils/pack-entity'; +import type { World } from '../../world'; +import type { EventType, QueryInstance } from '../types'; + +/** + * Check if an entity matches a tracking query with event handling. + * + * PERF: This is a hot path - optimizations applied: + * - Cache all property accesses at function start + * - Use `| 0` instead of `|| 0` (bitwise coerces undefined to 0) + * - Avoid optional chaining in inner loops + * - Cache array references before mutation + * - Early exits where possible + */ +export function checkQueryTracking( + world: World, + query: QueryInstance, + entity: Entity, + eventType: EventType, + eventGenerationId: number, + eventBitflag: number +): boolean { + // Cache all property accesses upfront + const staticBitmasks = query.staticBitmasks; + const trackingGroups = query.trackingGroups; + const generations = query.generations; + const traitInstancesAll = query.traitInstances.all; + const entityMasks = world[$internal].entityMasks; + const eid = getEntityId(entity); + + const generationsLen = generations.length; + const trackingGroupsLen = trackingGroups.length; + + // Early exit: no traits to check + if (traitInstancesAll.length === 0) return false; + + // FORK: `Or` is a query-wide constraint, but bitmasks are split per generation. + // Accumulate across generations instead of rejecting on the first generation + // whose Or mask does not match. See patches/koota+0.6.6.patch (now folded in). + let staticHasOr = false; + let staticOrMatched = false; + + // 1. Check static constraints (required/forbidden/or) + for (let i = 0; i < generationsLen; i++) { + const generationId = generations[i]; + const bitmask = staticBitmasks[i]; + if (!bitmask) continue; + + const required = bitmask.required; + const forbidden = bitmask.forbidden; + const or = bitmask.or; + + // PERF: Direct access + bitwise OR coerces undefined to 0 + const genMasks = entityMasks[generationId]; + const entityMask = genMasks ? genMasks[eid] | 0 : 0; + + // Check forbidden traits + if (forbidden && (entityMask & forbidden) !== 0) return false; + + // Check required traits + if (required && (entityMask & required) !== required) return false; + + // Check Or traits + if (or !== 0) { + staticHasOr = true; + if ((entityMask & or) !== 0) staticOrMatched = true; + } + } + + if (staticHasOr && !staticOrMatched) return false; + + // 2. Process tracking groups - update trackers and check cross-event invalidation + // Also track OR group state to avoid second loop when possible + let hasOrGroup = false; + let anyOrMatched = false; + + for (let i = 0; i < trackingGroupsLen; i++) { + const group = trackingGroups[i]; + const groupType = group.type; + const groupLogic = group.logic; + const groupBitmasks = group.bitmasks; + const groupBitmask = groupBitmasks[eventGenerationId]; + + // Check if this event affects this group's traits + if (groupBitmask && groupBitmask & eventBitflag) { + // Cross-event invalidation: + // - Remove event invalidates Added/Changed tracking + // - Add event invalidates Removed/Changed tracking + if (eventType === 'remove') { + if (groupType === 'add' || groupType === 'change') return false; + } else if (eventType === 'add') { + if (groupType === 'remove' || groupType === 'change') return false; + } + + // Update tracker if event type matches group type + if (groupType === eventType) { + // For change events, verify entity still has the trait + if (eventType === 'change') { + const genMasks = entityMasks[eventGenerationId]; + const entityMask = genMasks ? genMasks[eid] | 0 : 0; + if (!(entityMask & eventBitflag)) return false; + } + + // PERF: Cache tracker array reference before mutation + const groupTrackers = group.trackers; + let trackerArr = groupTrackers[eventGenerationId]; + if (!trackerArr) { + trackerArr = []; + groupTrackers[eventGenerationId] = trackerArr; + } + trackerArr[eid] = trackerArr[eid] | 0 | eventBitflag; + } + } + + // 3. Verify tracking group satisfaction (merged into same loop) + if (groupLogic === 'or') { + hasOrGroup = true; + if (!anyOrMatched) { + // Check if any trait in OR group has been tracked + const groupTrackers = group.trackers; + const bitmaskLen = groupBitmasks.length; + for (let genId = 0; genId < bitmaskLen; genId++) { + const mask = groupBitmasks[genId]; + if (!mask) continue; + const trackerArr = groupTrackers[genId]; + const tracker = trackerArr ? trackerArr[eid] | 0 : 0; + if (tracker & mask) { + anyOrMatched = true; + break; + } + } + } + } else { + // AND group: all traits must be tracked + const groupTrackers = group.trackers; + const bitmaskLen = groupBitmasks.length; + for (let genId = 0; genId < bitmaskLen; genId++) { + const mask = groupBitmasks[genId]; + if (!mask) continue; + const trackerArr = groupTrackers[genId]; + const tracker = trackerArr ? trackerArr[eid] | 0 : 0; + if ((tracker & mask) !== mask) { + return false; + } + } + } + } + + // If we have OR groups, at least one must match + if (hasOrGroup && !anyOrMatched) { + return false; + } + + return true; +} diff --git a/packages/koota/src/query/utils/check-query-with-relations.ts b/packages/koota/src/query/utils/check-query-with-relations.ts new file mode 100644 index 00000000..8a710369 --- /dev/null +++ b/packages/koota/src/query/utils/check-query-with-relations.ts @@ -0,0 +1,32 @@ +import type { Entity } from '../../entity/types'; +import { hasRelationPair, hasRelationTargetInSet } from '../../relation/relation'; +import type { World } from '../../world'; +import type { QueryInstance } from '../types'; +import { checkQuery } from './check-query'; + +/** + * Check if an entity matches a query with relation filters. + * Uses hybrid bitmask strategy: trait bitmasks first (fast), then relation checks. + */ +export function checkQueryWithRelations(world: World, query: QueryInstance, entity: Entity): boolean { + // First check trait bitmasks (fast) + if (!checkQuery(world, query, entity)) return false; + + // Then check relation pairs if any + if (query.relationFilters && query.relationFilters.length > 0) { + for (const pair of query.relationFilters) { + if (pair.targetQueryMatches) { + if (!hasRelationTargetInSet(world, pair.relation, entity, pair.targetQueryMatches)) { + return false; + } + continue; + } + + if (!hasRelationPair(world, entity, pair)) { + return false; + } + } + } + + return true; +} diff --git a/packages/koota/src/query/utils/check-query.ts b/packages/koota/src/query/utils/check-query.ts new file mode 100644 index 00000000..bf076d56 --- /dev/null +++ b/packages/koota/src/query/utils/check-query.ts @@ -0,0 +1,47 @@ +import { $internal } from '../../common'; +import type { Entity } from '../../entity/types'; +import { getEntityId } from '../../entity/utils/pack-entity'; +import type { World } from '../../world'; +import type { QueryInstance } from '../types'; + +/** + * Check if an entity matches a non-tracking query. + * For tracking queries, use checkQueryTracking instead. + */ +export function checkQuery(world: World, query: QueryInstance, entity: Entity): boolean { + const staticBitmasks = query.staticBitmasks; + const generations = query.generations; + const ctx = world[$internal]; + const eid = getEntityId(entity); + + if (query.traitInstances.all.length === 0) return false; + + // FORK: `Or` is a query-wide constraint, but bitmasks are split per generation. + // Accumulate across generations instead of rejecting on the first generation + // whose Or mask does not match. See patches/koota+0.6.6.patch (now folded in). + let staticHasOr = false; + let staticOrMatched = false; + + for (let i = 0; i < generations.length; i++) { + const generationId = generations[i]; + const bitmask = staticBitmasks[i]; + if (!bitmask) continue; + + const required = bitmask.required; + const forbidden = bitmask.forbidden; + const or = bitmask.or; + const entityMask = ctx.entityMasks[generationId]?.[eid] || 0; + + if (!forbidden && !required && !or) return false; + if (forbidden && (entityMask & forbidden) !== 0) return false; + if (required && (entityMask & required) !== required) return false; + if (or !== 0) { + staticHasOr = true; + if ((entityMask & or) !== 0) staticOrMatched = true; + } + } + + if (staticHasOr && !staticOrMatched) return false; + + return true; +} diff --git a/packages/koota/src/query/utils/create-binary-string.ts b/packages/koota/src/query/utils/create-binary-string.ts new file mode 100644 index 00000000..b7d2f583 --- /dev/null +++ b/packages/koota/src/query/utils/create-binary-string.ts @@ -0,0 +1,14 @@ +export function createBinaryString(nMask: number) { + // nMask must be between -2147483648 and 2147483647 + let nFlag = 0; + let nShifted = nMask; + let sMask = ''; + + while (nFlag < 32) { + sMask += String(nShifted >>> 31); + nShifted <<= 1; + nFlag++; + } + + return sMask; +} diff --git a/packages/koota/src/query/utils/create-query-hash.ts b/packages/koota/src/query/utils/create-query-hash.ts new file mode 100644 index 00000000..b6cdbbdf --- /dev/null +++ b/packages/koota/src/query/utils/create-query-hash.ts @@ -0,0 +1,68 @@ +import { $internal } from '../../common'; +import type { Relation } from '../../relation/types'; +import { isRelationPair } from '../../relation/utils/is-relation'; +import type { Trait } from '../../trait/types'; +import { isModifier } from '../modifier'; +import { isQuery } from './is-query'; +import type { QueryHash, QueryParameter } from '../types'; + +const MODIFIER_FACTOR = 100000; +const RELATION_FACTOR = 10000000; +const RELATION_OFFSET = 5000000; +// Offset for target-query relation pairs so they don't collide with concrete target encodings. +const RELATION_QUERY_OFFSET = 9000000; + +// Reusable buffer — avoids allocation per call. +const sortBuf = new Float64Array(1024); + +// Maps a sub-query hash string to a stable numeric id for encoding in the Float64Array. +let nextQueryId = 1; +const queryHashToId = new Map(); + +function queryHashNumericId(hash: string): number { + let id = queryHashToId.get(hash); + if (id === undefined) { + id = nextQueryId++; + queryHashToId.set(hash, id); + } + return id; +} + +export const createQueryHash = (parameters: QueryParameter[]): QueryHash => { + let cursor = 0; + + for (let i = 0; i < parameters.length; i++) { + const param = parameters[i]; + + if (isRelationPair(param)) { + const relationId = (param.relation as Relation)[$internal].trait.id; + + if (param.targetQuery) { + const subHash = isQuery(param.targetQuery) + ? param.targetQuery.hash + : createQueryHash([...param.targetQuery]); + sortBuf[cursor++] = + relationId * RELATION_FACTOR + queryHashNumericId(subHash) + RELATION_QUERY_OFFSET; + continue; + } + + const target = param.target; + const targetId = typeof target === 'number' ? target : -1; + sortBuf[cursor++] = relationId * RELATION_FACTOR + targetId + RELATION_OFFSET; + continue; + } + + if (isModifier(param)) { + for (let j = 0; j < param.traitIds.length; j++) { + sortBuf[cursor++] = param.id * MODIFIER_FACTOR + param.traitIds[j]; + } + continue; + } + + sortBuf[cursor++] = (param as Trait).id; + } + + const filled = sortBuf.subarray(0, cursor); + filled.sort(); + return filled.join(','); +}; diff --git a/packages/koota/src/query/utils/is-query.ts b/packages/koota/src/query/utils/is-query.ts new file mode 100644 index 00000000..dac8bc8f --- /dev/null +++ b/packages/koota/src/query/utils/is-query.ts @@ -0,0 +1,10 @@ +import type { Brand } from '../../common'; +import type { Query } from '../types'; +import { $queryRef } from '../symbols'; + +/** + * Check if a value is a Query + */ +export /* @inline @pure */ function isQuery(value: unknown): value is Query { + return (value as Brand | null | undefined)?.[$queryRef] as unknown as boolean; +} diff --git a/packages/koota/src/query/utils/tracking-cursor.ts b/packages/koota/src/query/utils/tracking-cursor.ts new file mode 100644 index 00000000..61f29a23 --- /dev/null +++ b/packages/koota/src/query/utils/tracking-cursor.ts @@ -0,0 +1,33 @@ +import { $internal } from '../../common'; +import type { World } from '../../world'; + +// Some values are reserved. +// 0 - has +// 1 - not +// 2 - or +let cursor = 3; + +export function createTrackingId() { + return cursor++; +} + +export function getTrackingCursor() { + return cursor; +} + +export function setTrackingMasks(world: World, id: number) { + const ctx = world[$internal]; + const snapshot = structuredClone(ctx.entityMasks); + ctx.trackingSnapshots.set(id, snapshot); + + // For dirty and changed masks, make clone of entity masks and set all bits to 0. + ctx.dirtyMasks.set( + id, + snapshot.map((mask) => mask.map(() => 0)) + ); + + ctx.changedMasks.set( + id, + snapshot.map((mask) => mask.map(() => 0)) + ); +} diff --git a/packages/koota/src/relation/ordered-list.ts b/packages/koota/src/relation/ordered-list.ts new file mode 100644 index 00000000..389d41ec --- /dev/null +++ b/packages/koota/src/relation/ordered-list.ts @@ -0,0 +1,235 @@ +import type { Entity } from '../entity/types'; +import type { World } from '../world'; +import type { Relation } from './types'; +import type { Trait } from '../trait/types'; +import { addTrait, removeTrait } from '../trait/trait'; +import { setChanged } from '../query/modifiers/changed'; + +/** + * An ordered list of entities that syncs with a relation. + * Extends Array to provide native array methods while keeping relation pairs in sync. + * + * @experimental This API is experimental and may change in future versions. + * Please provide feedback on GitHub or Discord. + */ +export class OrderedList extends Array { + private world: World; + private parent: Entity; + private relation: Relation; + private orderedTrait: Trait; + private _syncing: boolean = false; + + constructor( + world: World, + parent: Entity, + relation: Relation, + orderedTrait: Trait, + items: Entity[] = [] + ) { + super(...items); + this.world = world; + this.parent = parent; + this.relation = relation; + this.orderedTrait = orderedTrait; + } + + get [Symbol.toStringTag]() { + return 'OrderedList'; + } + + /** + * Add entities to the end of the list and add relation pairs. + */ + override push(...items: Entity[]): number { + this._syncing = true; + try { + for (const item of items) { + addTrait(this.world, item, this.relation(this.parent)); + } + const result = super.push(...items); + setChanged(this.world, this.parent, this.orderedTrait); + return result; + } finally { + this._syncing = false; + } + } + + /** + * Remove and return the last entity, removing its relation pair. + */ + override pop(): Entity | undefined { + this._syncing = true; + try { + const item = super.pop(); + if (item !== undefined) { + removeTrait(this.world, item, this.relation(this.parent)); + setChanged(this.world, this.parent, this.orderedTrait); + } + return item; + } finally { + this._syncing = false; + } + } + + /** + * Remove and return the first entity, removing its relation pair. + */ + override shift(): Entity | undefined { + this._syncing = true; + try { + const item = super.shift(); + if (item !== undefined) { + removeTrait(this.world, item, this.relation(this.parent)); + setChanged(this.world, this.parent, this.orderedTrait); + } + return item; + } finally { + this._syncing = false; + } + } + + /** + * Add entities to the beginning of the list and add relation pairs. + */ + override unshift(...items: Entity[]): number { + this._syncing = true; + try { + for (const item of items) { + addTrait(this.world, item, this.relation(this.parent)); + } + const result = super.unshift(...items); + setChanged(this.world, this.parent, this.orderedTrait); + return result; + } finally { + this._syncing = false; + } + } + + /** + * Remove and/or insert entities, syncing relation pairs. + */ + override splice(start: number, deleteCount?: number, ...items: Entity[]): Entity[] { + this._syncing = true; + try { + const removed = super.splice(start, deleteCount ?? 0, ...items); + + // Remove relation pairs for removed items + for (const item of removed) { + removeTrait(this.world, item, this.relation(this.parent)); + } + + // Add relation pairs for inserted items + for (const item of items) { + addTrait(this.world, item, this.relation(this.parent)); + } + + if (removed.length > 0 || items.length > 0) { + setChanged(this.world, this.parent, this.orderedTrait); + } + + return removed; + } finally { + this._syncing = false; + } + } + + /** + * Sort the list in place. Does not modify relations. + */ + override sort(compareFn?: (a: Entity, b: Entity) => number): this { + super.sort(compareFn); + setChanged(this.world, this.parent, this.orderedTrait); + return this; + } + + /** + * Reverse the list in place. Does not modify relations. + */ + override reverse(): this { + super.reverse(); + setChanged(this.world, this.parent, this.orderedTrait); + return this; + } + + /** + * Override map to return a plain array instead of OrderedList. + */ + override map(callbackfn: (value: Entity, index: number, array: Entity[]) => U): U[] { + return Array.prototype.map.call(this, callbackfn) as U[]; + } + + /** + * Override filter to return a plain array instead of OrderedList. + */ + override filter(predicate: (value: Entity, index: number, array: Entity[]) => boolean): Entity[] { + return Array.prototype.filter.call(this, predicate) as Entity[]; + } + + /** + * Override slice to return a plain array instead of OrderedList. + */ + override slice(start?: number, end?: number): Entity[] { + return Array.prototype.slice.call(this, start, end) as Entity[]; + } + + /** + * Move an entity to a specific index in the list. + * Does not modify the relation, only reorders. + */ + moveTo(item: Entity, toIndex: number): void { + const fromIndex = this.indexOf(item); + if (fromIndex === -1) { + throw new Error('Item not found in OrderedList'); + } + if (fromIndex === toIndex) return; + + // Remove from current position + super.splice(fromIndex, 1); + + // Insert at new position + super.splice(toIndex, 0, item); + + setChanged(this.world, this.parent, this.orderedTrait); + } + + /** + * Insert an entity at a specific index and add its relation pair. + */ + insert(item: Entity, index: number): void { + this._syncing = true; + try { + addTrait(this.world, item, this.relation(this.parent)); + super.splice(index, 0, item); + setChanged(this.world, this.parent, this.orderedTrait); + } finally { + this._syncing = false; + } + } + + /** + * Internal method to append without triggering relation add. + * Used by the sync system when a relation is added externally. + */ + _appendWithoutSync(item: Entity): void { + // Only append if not currently syncing (prevents double-add) + if (!this._syncing) { + super.push(item); + setChanged(this.world, this.parent, this.orderedTrait); + } + } + + /** + * Internal method to remove without triggering relation remove. + * Used by the sync system when a relation is removed externally. + */ + _removeWithoutSync(item: Entity): void { + // Only remove if not currently syncing (prevents double-remove) + if (!this._syncing) { + const index = this.indexOf(item); + if (index !== -1) { + super.splice(index, 1); + setChanged(this.world, this.parent, this.orderedTrait); + } + } + } +} diff --git a/packages/koota/src/relation/ordered.ts b/packages/koota/src/relation/ordered.ts new file mode 100644 index 00000000..3a4a0c6c --- /dev/null +++ b/packages/koota/src/relation/ordered.ts @@ -0,0 +1,100 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { trait, registerTrait } from '../trait/trait'; +import { getTraitInstance } from '../trait/trait-instance'; +import type { Trait } from '../trait/types'; +import type { World } from '../world'; +import { OrderedList } from './ordered-list'; +import { $orderedTargetsTrait } from './symbols'; +import type { OrderedRelation, Relation } from './types'; + +/** + * Creates a trait that maintains an ordered list of entities related by a relation. + * The list automatically syncs with the relation - adding/removing from the list + * adds/removes the relation pair, and vice versa. + * + * @experimental This API is experimental and may change in future versions. + * Please provide feedback on GitHub or Discord. + * + * @example + * ```ts + * const ChildOf = relation(); + * const OrderedChildren = ordered(ChildOf); + * + * const parent = world.spawn(OrderedChildren); + * const children = parent.get(OrderedChildren); + * children.push(child1); // adds ChildOf(parent) to child1 + * children.splice(0, 1); // removes ChildOf(parent) from child1 + * ``` + */ +export function ordered(relation: Relation): OrderedRelation { + const orderedTrait = trait(() => [] as Entity[]); + + Object.defineProperty(orderedTrait, $orderedTargetsTrait, { + value: { relation }, + writable: false, + enumerable: false, + configurable: false, + }); + + return orderedTrait as unknown as OrderedRelation; +} + +/** + * Check if a trait is an ordered trait. + */ +export /* @inline @pure */ function isOrderedTrait(trait: Trait): trait is OrderedRelation { + return $orderedTargetsTrait in trait; +} + +/** + * Get the relation linked to an ordered trait. + */ +export /* @inline @pure */ function getOrderedTraitRelation(trait: OrderedRelation): Relation { + return trait[$orderedTargetsTrait].relation; +} + +/** + * Setup sync subscriptions for an ordered trait. + * Called during trait registration to wire up bidirectional sync. + */ +export function setupOrderedTraitSync(world: World, orderedTrait: OrderedRelation): void { + const ctx = world[$internal]; + const relation = getOrderedTraitRelation(orderedTrait); + const relationTrait = relation[$internal].trait; + + const orderedInstance = getTraitInstance(ctx.traitInstances, orderedTrait); + if (!orderedInstance) return; + + let relationInstance = getTraitInstance(ctx.traitInstances, relationTrait); + if (!relationInstance) { + registerTrait(world, relationTrait); + relationInstance = getTraitInstance(ctx.traitInstances, relationTrait)!; + } + + const { generationId, bitflag, store } = orderedInstance; + const { entityMasks, entityIndex } = ctx; + const traitCtx = orderedTrait[$internal]; + + const getList = (parent: Entity): OrderedList | undefined => { + const eid = getEntityId(parent); + return entityMasks[generationId]?.[eid] & bitflag + ? (traitCtx.get(eid, store) as OrderedList) + : undefined; + }; + + type RelationSub = (entity: Entity, target: Entity) => void; + + (relationInstance.addSubscriptions as Set).add((child, parent) => { + getList(parent)?._appendWithoutSync(child); + }); + + (relationInstance.removeSubscriptions as Set).add((child, parent) => { + const eid = getEntityId(parent); + const denseIdx = entityIndex.sparse[eid]; + if (denseIdx !== undefined && getEntityId(entityIndex.dense[denseIdx]) === eid) { + getList(parent)?._removeWithoutSync(child); + } + }); +} diff --git a/packages/koota/src/relation/relation.ts b/packages/koota/src/relation/relation.ts new file mode 100644 index 00000000..a402b287 --- /dev/null +++ b/packages/koota/src/relation/relation.ts @@ -0,0 +1,630 @@ +import type { SparseSet } from '../collections'; +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { isQuery } from '../query/utils/is-query'; +import { checkQueryWithRelations } from '../query/utils/check-query-with-relations'; +import type { Schema } from '../storage'; +import { hasTrait, trait } from '../trait/trait'; +import { getTraitInstance } from '../trait/trait-instance'; +import type { QueryParameter } from '../query/types'; +import type { Trait, TraitInstance } from '../trait/types'; +import type { World } from '../world'; +import type { Relation, RelationPair } from './types'; +import { $relation, $relationPair } from './symbols'; + +/** + * Creates a relation definition. + * Relations are stored efficiently - one trait per relation type, not per target. + * Targets are stored in TraitInstance.relationTargets. + */ +function createRelation>(definition?: { + exclusive?: boolean; + autoDestroy?: 'orphan' | 'source' | 'target'; + /** @deprecated Use `autoDestroy: 'orphan'` instead */ + autoRemoveTarget?: boolean; + store?: S; +}): Relation> { + // Create the underlying trait for this relation + const relationTrait = trait(definition?.store ?? ({} as S)) as unknown as Trait; + const traitCtx = relationTrait[$internal]; + + // Mark the trait as a relation trait + traitCtx.relation = null!; // Will be set below after relation is created + + // Handle autoDestroy option - 'orphan' is an alias for 'source' + let autoDestroy: 'source' | 'target' | false = false; + if (definition?.autoDestroy === 'orphan' || definition?.autoDestroy === 'source') { + autoDestroy = 'source'; + } else if (definition?.autoDestroy === 'target') { + autoDestroy = 'target'; + } + + // Handle deprecated autoRemoveTarget option + if (definition?.autoRemoveTarget) { + console.warn("Koota: 'autoRemoveTarget' is deprecated. Use 'autoDestroy: \"orphan\"' instead."); + autoDestroy = 'source'; + } + + const relationCtx = { + trait: relationTrait, + exclusive: definition?.exclusive ?? false, + autoDestroy, + }; + + function relationFn(...args: any[]): RelationPair> { + const firstArg = args[0]; + if (firstArg === undefined) throw Error('Relation target is undefined'); + + if (firstArg === '*' || typeof firstArg === 'number') { + return { + [$relationPair]: true, + relation: relationFn as unknown as Relation>, + target: firstArg, + params: args[1], + }; + } + + if (isQuery(firstArg)) { + if (args.length > 1) throw Error('Query relations do not accept additional parameters.'); + return { + [$relationPair]: true, + relation: relationFn as unknown as Relation>, + targetQuery: firstArg, + }; + } + + return { + [$relationPair]: true, + relation: relationFn as unknown as Relation>, + targetQuery: args as QueryParameter[], + }; + } + + const relation = Object.assign(relationFn, { + [$internal]: relationCtx, + }) as Relation>; + + // Add symbol brand for fast type checking + Object.defineProperty(relation, $relation, { + value: true, + writable: false, + enumerable: false, + configurable: false, + }); + + // Set the back-reference from trait to relation + traitCtx.relation = relation; + + return relation; +} + +export const relation = createRelation; + +const EMPTY_ENTITY_ARRAY: readonly Entity[] = Object.freeze([]) as readonly Entity[]; + +/** Bumps the version of a target's source bucket so cached orderings can detect changes. */ +/* @inline */ function bumpRelationSourceVersion(traitData: TraitInstance, targetId: number): void { + const versions = (traitData.relationSourceVersions ??= []); + versions[targetId] = (versions[targetId] ?? 0) + 1; +} + +/** + * Version of the source bucket of `target`. Changes whenever an entity starts or stops + * pointing at `target` through `relation`. + */ +export function getRelationSourceVersion( + world: World, + relation: Relation, + target: Entity +): number { + const traitData = getTraitInstance( + world[$internal].traitInstances, + relation[$internal].trait + ); + return traitData?.relationSourceVersions?.[getEntityId(target)] ?? 0; +} + +function addToRelationSources(traitData: TraitInstance, entity: Entity, target: Entity): void { + const targetId = getEntityId(target); + const buckets = (traitData.relationSourcesByTarget ??= []); + const bucket = (buckets[targetId] ??= []); + bucket.push(entity); + bumpRelationSourceVersion(traitData, targetId); +} + +function removeFromRelationSources(traitData: TraitInstance, entity: Entity, target: Entity): void { + const targetId = getEntityId(target); + const bucket = traitData.relationSourcesByTarget?.[targetId]; + if (!bucket) return; + const idx = bucket.indexOf(entity); + if (idx === -1) return; + const last = bucket.length - 1; + if (idx !== last) bucket[idx] = bucket[last]; + bucket.pop(); + bumpRelationSourceVersion(traitData, targetId); +} + +/** + * Get the targets for a relation on an entity. + * Returns an array of target entity IDs. + */ +export /* @inline */ function getRelationTargets( + world: World, + relation: Relation, + entity: Entity +): readonly Entity[] { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + + const traitData = getTraitInstance(ctx.traitInstances, relationCtx.trait); + if (!traitData || !traitData.relationTargets) return []; + + const eid = getEntityId(entity); + + if (relationCtx.exclusive) { + const target = (traitData.relationTargets as Array)[eid]; + return target !== undefined ? [target as Entity] : []; + } else { + const targets = (traitData.relationTargets as number[][])[eid]; + return targets !== undefined ? (targets.slice() as Entity[]) : []; + } +} + +/** + * Get the first target for a relation on an entity. + * Returns the first target entity ID, or undefined if none exists. + * Optimized version that avoids array allocation. + */ +export /* @inline */ function getFirstRelationTarget( + world: World, + relation: Relation, + entity: Entity +): Entity | undefined { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + + const traitData = getTraitInstance(ctx.traitInstances, relationCtx.trait); + if (!traitData || !traitData.relationTargets) return undefined; + + const eid = getEntityId(entity); + + if (relationCtx.exclusive) { + const target = (traitData.relationTargets as Array)[eid]; + return target; + } else { + const targets = (traitData.relationTargets as number[][])[eid]; + return targets?.[0] as Entity | undefined; + } +} + +/** + * Get the index of a target in the relation's target array. + * Returns -1 if not found. Used for accessing per-target store data. + */ +export /* @inline */ function getTargetIndex( + world: World, + relation: Relation, + entity: Entity, + target: Entity +): number { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + const baseTrait = relationCtx.trait; + + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData || !traitData.relationTargets) return -1; + + const eid = getEntityId(entity); + + if (relationCtx.exclusive) { + return (traitData.relationTargets as Array)[eid] === target ? 0 : -1; + } else { + const targets = (traitData.relationTargets as number[][])[eid]; + return targets ? targets.indexOf(target) : -1; + } +} + +/** + * Check if an entity has a relation to a specific target. + */ +export /* @inline */ function hasRelationToTarget( + world: World, + relation: Relation, + entity: Entity, + target: Entity +): boolean { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + const baseTrait = relationCtx.trait; + + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData || !traitData.relationTargets) return false; + + const eid = getEntityId(entity); + + if (relationCtx.exclusive) { + return (traitData.relationTargets as Array)[eid] === target; + } else { + const targets = (traitData.relationTargets as number[][])[eid]; + return targets ? targets.includes(target) : false; + } +} + +/** + * Add a relation target to an entity. + * Returns the index of the target in the targets array. + * If the target already exists, returns -1. + */ +export function addRelationTarget( + world: World, + relation: Relation, + entity: Entity, + target: Entity +): number { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + const baseTrait = relationCtx.trait; + + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData) return -1; + + if (!traitData.relationTargets) { + traitData.relationTargets = []; + } + + const eid = getEntityId(entity); + + let targetIndex: number; + + if (relationCtx.exclusive) { + const targets = traitData.relationTargets as Array; + const previousTarget = targets[eid]; + // No-op if unchanged + if (previousTarget === target) return -1; + if (previousTarget !== undefined) { + removeFromRelationSources(traitData, entity, previousTarget as Entity); + } + targets[eid] = target; + targetIndex = 0; + } else { + const targetsArray = traitData.relationTargets as number[][]; + if (!targetsArray[eid]) { + targetsArray[eid] = []; + } + + // Check if already exists + const existingIndex = targetsArray[eid].indexOf(target); + if (existingIndex !== -1) { + return -1; + } + + targetIndex = targetsArray[eid].length; + targetsArray[eid].push(target); + } + + addToRelationSources(traitData, entity, target); + updateQueriesForRelationChange(world, relation, entity); + + return targetIndex; +} + +/** + * Remove a relation target from an entity. + * Returns the removed index and whether this was the last target. + */ +export function removeRelationTarget( + world: World, + relation: Relation, + entity: Entity, + target: Entity +): { removedIndex: number; wasLastTarget: boolean } { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + const relationTrait = relationCtx.trait; + + const data = getTraitInstance(ctx.traitInstances, relationTrait); + if (!data || !data.relationTargets) return { removedIndex: -1, wasLastTarget: false }; + + const eid = getEntityId(entity); + + let removedIndex = -1; + let hasRemainingTargets = false; + + if (relationCtx.exclusive) { + const targets = data.relationTargets as Array; + if (targets[eid] === target) { + removeFromRelationSources(data, entity, target); + targets[eid] = undefined; + removedIndex = 0; + hasRemainingTargets = false; + clearRelationDataInternal(data.store, relationTrait[$internal].type, eid, 0, true); + } + } else { + const targetsArray = data.relationTargets as number[][]; + const entityTargets = targetsArray[eid]; + if (entityTargets) { + const idx = entityTargets.indexOf(target); + if (idx !== -1) { + const lastIdx = entityTargets.length - 1; + removeFromRelationSources(data, entity, target); + if (idx !== lastIdx) entityTargets[idx] = entityTargets[lastIdx]; + entityTargets.pop(); + swapAndPopRelationData(data.store, relationTrait[$internal].type, eid, idx, lastIdx); + removedIndex = idx; + hasRemainingTargets = entityTargets.length > 0; + } + } + } + + if (removedIndex !== -1) updateQueriesForRelationChange(world, relation, entity); + + const wasLastTarget = removedIndex !== -1 && !hasRemainingTargets; + return { removedIndex, wasLastTarget }; +} + +/** + * Update queries when relation targets change. + * Called after addRelationTarget or removeRelationTarget to keep queries in sync. + */ +function updateQueriesForRelationChange( + world: World, + relation: Relation, + entity: Entity +): void { + const ctx = world[$internal]; + const baseTrait = relation[$internal].trait; + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData) return; + + // Update queries indexed by this relation (much faster than iterating all queries) + // All queries in relationQueries already filter by this relation + for (const query of traitData.relationQueries) { + // Re-check entity against query + const match = checkQueryWithRelations(world, query, entity); + if (match) { + query.add(entity); + } else { + query.remove(world, entity); + } + } +} + +/** Swap-and-pop data arrays for non-exclusive relations */ +function swapAndPopRelationData( + store: any, + type: string, + eid: number, + idx: number, + lastIdx: number +): void { + if (type === 'aos') { + const arr = store[eid]; + if (arr) { + if (idx !== lastIdx) arr[idx] = arr[lastIdx]; + arr.pop(); + } + } else { + for (const key in store) { + const arr = store[key][eid]; + if (arr) { + if (idx !== lastIdx) arr[idx] = arr[lastIdx]; + arr.pop(); + } + } + } +} + +/** Clear data for exclusive relations */ +function clearRelationDataInternal( + store: any, + type: string, + eid: number, + _idx: number, + exclusive: boolean +): void { + if (!exclusive) return; + if (type === 'aos') { + store[eid] = undefined; + } else { + for (const key in store) { + store[key][eid] = undefined; + } + } +} + +/** + * Remove all relation targets from an entity. + * Used for bulk removal when the base trait is also being removed. + */ +export function removeAllRelationTargets( + world: World, + relation: Relation, + entity: Entity +): void { + const targets = getRelationTargets(world, relation, entity); + for (const target of targets) { + removeRelationTarget(world, relation, entity, target); + } +} + +/** + * Get all entities that have a specific relation targeting a specific entity. + * Returns a fresh snapshot so callers can safely iterate while relations mutate. + */ +export function getEntitiesWithRelationTo( + world: World, + relation: Relation, + target: Entity +): readonly Entity[] { + const ctx = world[$internal]; + if (!world.has(target)) return EMPTY_ENTITY_ARRAY; + + const baseTrait = relation[$internal].trait; + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData?.relationSourcesByTarget) return EMPTY_ENTITY_ARRAY; + return traitData.relationSourcesByTarget[getEntityId(target)]?.slice() ?? EMPTY_ENTITY_ARRAY; +} + +export function hasRelationTargetInSet( + world: World, + relation: Relation, + entity: Entity, + matches: SparseSet +): boolean { + const ctx = world[$internal]; + const relationCtx = relation[$internal]; + const traitData = getTraitInstance(ctx.traitInstances, relationCtx.trait); + if (!traitData?.relationTargets) return false; + + const eid = getEntityId(entity); + + if (relationCtx.exclusive) { + const target = (traitData.relationTargets as Array)[eid]; + return target !== undefined && matches.has(target); + } + + const targets = (traitData.relationTargets as number[][])[eid]; + if (!targets) return false; + + for (let i = 0; i < targets.length; i++) { + if (matches.has(targets[i]!)) return true; + } + + return false; +} + +/** + * Set data for a specific relation target using target index. + * For exclusive relations, index is always 0. + * For non-exclusive, index corresponds to position in targets array. + */ +export function setRelationDataAtIndex( + world: World, + entity: Entity, + relation: Relation, + targetIndex: number, + value: Record +): void { + const relationCtx = relation[$internal]; + const baseTrait = relationCtx.trait; + const traitData = getTraitInstance(world[$internal].traitInstances, baseTrait); + if (!traitData) return; + + const store = traitData.store; + const eid = getEntityId(entity); + + if (baseTrait[$internal].type === 'aos') { + if (relationCtx.exclusive) { + (store as unknown[])[eid] = value; + } else { + ((store as unknown[][])[eid] ??= [])[targetIndex] = value; + } + return; + } + + // SoA + if (relationCtx.exclusive) { + for (const key in value) { + (store as Record)[key][eid] = (value as Record)[key]; + } + } else { + for (const key in value) { + (((store as Record>)[key][eid] ??= []) as unknown[])[ + targetIndex + ] = (value as Record)[key]; + } + } +} + +/** + * Set data for a specific relation target. + */ +export function setRelationData( + world: World, + entity: Entity, + relation: Relation, + target: Entity, + value: Record +): void { + const targetIndex = getTargetIndex(world, relation, entity, target); + if (targetIndex === -1) return; + setRelationDataAtIndex(world, entity, relation, targetIndex, value); +} + +/** + * Get data for a specific relation target. + */ +export function getRelationData( + world: World, + entity: Entity, + relation: Relation, + target: Entity +): unknown { + const ctx = world[$internal]; + const baseTrait = relation[$internal].trait; + const traitData = getTraitInstance(ctx.traitInstances, baseTrait); + if (!traitData) return undefined; + + const targetIndex = getTargetIndex(world, relation, entity, target); + if (targetIndex === -1) return undefined; + + const traitCtx = baseTrait[$internal]; + const store = traitData.store; + const eid = getEntityId(entity); + const relationCtx = relation[$internal]; + + if (traitCtx.type === 'aos') { + if (relationCtx.exclusive) { + return (store as unknown[])[eid]; + } else { + return (store as unknown[][])[eid]?.[targetIndex]; + } + } else { + // SoA: reconstruct object from store arrays + const result: Record = {}; + const storeRecord = store as Record>; + for (const key in store) { + if (relationCtx.exclusive) { + result[key] = storeRecord[key][eid]; + } else { + result[key] = (storeRecord[key][eid] as unknown[] | undefined)?.[targetIndex]; + } + } + return result; + } +} + +/** + * Check if entity has a relation pair. + */ +export function hasRelationPair(world: World, entity: Entity, pair: RelationPair): boolean { + const relation = pair.relation; + const target = pair.target; + const targetQuery = pair.targetQuery; + + // Check if entity has the base trait + if (!hasTrait(world, entity, relation[$internal].trait)) return false; + + if (targetQuery) { + const matchingTargets = isQuery(targetQuery) + ? world.query(targetQuery) + : world.query(...targetQuery); + if (!matchingTargets.length) return false; + + const targets = getRelationTargets(world, relation, entity); + for (let i = 0; i < targets.length; i++) { + if (matchingTargets.includes(targets[i])) return true; + } + + return false; + } + + // Wildcard target + if (target === '*') return true; + + // Specific target + if (typeof target === 'number') return hasRelationToTarget(world, relation, entity, target); + + return false; +} diff --git a/packages/koota/src/relation/symbols.ts b/packages/koota/src/relation/symbols.ts new file mode 100644 index 00000000..a740c28d --- /dev/null +++ b/packages/koota/src/relation/symbols.ts @@ -0,0 +1,3 @@ +export const $relationPair = Symbol.for('relationPair'); +export const $relation = Symbol.for('relation'); +export const $orderedTargetsTrait = Symbol.for('orderedTargetsTrait'); diff --git a/packages/koota/src/relation/types.ts b/packages/koota/src/relation/types.ts new file mode 100644 index 00000000..601f5ea3 --- /dev/null +++ b/packages/koota/src/relation/types.ts @@ -0,0 +1,50 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import type { Query, QueryParameter } from '../query/types'; +import type { Trait } from '../trait/types'; +import type { OrderedList } from './ordered-list'; +import { $orderedTargetsTrait, $relation, $relationPair } from './symbols'; + +export type RelationTarget = Entity | '*'; +export type RelationInputTarget = + RelationTarget | Query | readonly QueryParameter[]; + +export interface ConcreteRelationPair { + readonly [$relationPair]: true; + readonly relation: Relation; + readonly target: RelationTarget; + readonly targetQuery?: undefined; + readonly params?: Record; +} + +export interface QueryRelationPair { + readonly [$relationPair]: true; + readonly relation: Relation; + readonly target?: undefined; + readonly targetQuery: Query | readonly QueryParameter[]; + readonly params?: undefined; +} + +/** A pair represents a relation + target combination */ +export type RelationPair = ConcreteRelationPair | QueryRelationPair; + +type RelationCall = { + (target: RelationTarget, params?: Record): ConcreteRelationPair; + (targetQuery: Query): QueryRelationPair; + (...targetQuery: [QueryParameter, ...QueryParameter[]]): QueryRelationPair; +}; + +export type Relation = { + readonly [$relation]: true; + [$internal]: { + trait: T; + exclusive: boolean; + autoDestroy: 'source' | 'target' | false; + }; +} & RelationCall; + +export interface OrderedRelation extends Trait<() => OrderedList> { + [$orderedTargetsTrait]: { + relation: Relation; + }; +} diff --git a/packages/koota/src/relation/utils/is-relation.ts b/packages/koota/src/relation/utils/is-relation.ts new file mode 100644 index 00000000..1cc3c8b0 --- /dev/null +++ b/packages/koota/src/relation/utils/is-relation.ts @@ -0,0 +1,20 @@ +import type { Brand } from '../../common'; +import type { Trait } from '../../trait/types'; +import type { Relation, RelationPair } from '../types'; +import { $relation, $relationPair } from '../symbols'; + +/** + * Check if a value is a Relation + */ +export /* @inline @pure */ function isRelation(value: unknown): value is Relation { + return (value as Brand | null | undefined)?.[$relation] as unknown as boolean; +} + +/** + * Check if a value is a RelationPair + */ +export /* @inline @pure */ function isRelationPair(value: unknown): value is RelationPair { + return (value as Brand | null | undefined)?.[ + $relationPair + ] as unknown as boolean; +} diff --git a/packages/koota-solid/src/hooks/use-actions.ts b/packages/koota/src/solid/hooks/use-actions.ts similarity index 83% rename from packages/koota-solid/src/hooks/use-actions.ts rename to packages/koota/src/solid/hooks/use-actions.ts index 1e4807a6..d5d0fb97 100644 --- a/packages/koota-solid/src/hooks/use-actions.ts +++ b/packages/koota/src/solid/hooks/use-actions.ts @@ -1,4 +1,4 @@ -import type { World } from 'koota'; +import type { World } from '../../index'; import { useWorld } from '../world/use-world'; export function useActions any>>( diff --git a/packages/koota-solid/src/hooks/use-has.ts b/packages/koota/src/solid/hooks/use-has.ts similarity index 97% rename from packages/koota-solid/src/hooks/use-has.ts rename to packages/koota/src/solid/hooks/use-has.ts index 7b7bfb94..573a1cca 100644 --- a/packages/koota-solid/src/hooks/use-has.ts +++ b/packages/koota/src/solid/hooks/use-has.ts @@ -1,4 +1,4 @@ -import { $internal, $relationPair, type Entity, type RelationPair, type Trait, type World } from 'koota'; +import { $internal, $relationPair, type Entity, type RelationPair, type Trait, type World } from '../../index'; import { createComputed, createSignal, onCleanup, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, createStableTrait, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/hooks/use-query-first.ts b/packages/koota/src/solid/hooks/use-query-first.ts similarity index 81% rename from packages/koota-solid/src/hooks/use-query-first.ts rename to packages/koota/src/solid/hooks/use-query-first.ts index 6417b9cc..dd354d38 100644 --- a/packages/koota-solid/src/hooks/use-query-first.ts +++ b/packages/koota/src/solid/hooks/use-query-first.ts @@ -1,4 +1,4 @@ -import type { Entity, QueryParameter } from 'koota'; +import type { Entity, QueryParameter } from '../../index'; import type { Accessor } from 'solid-js'; import { useQuery } from './use-query'; diff --git a/packages/koota-solid/src/hooks/use-query.ts b/packages/koota/src/solid/hooks/use-query.ts similarity index 97% rename from packages/koota-solid/src/hooks/use-query.ts rename to packages/koota/src/solid/hooks/use-query.ts index e0597fab..c88dd4df 100644 --- a/packages/koota-solid/src/hooks/use-query.ts +++ b/packages/koota/src/solid/hooks/use-query.ts @@ -1,4 +1,4 @@ -import { $internal, createQuery, type QueryParameter, type QueryResult } from 'koota'; +import { $internal, createQuery, type QueryParameter, type QueryResult } from '../../index'; import { createSignal, onCleanup, type Accessor } from 'solid-js'; import { useWorld } from '../world/use-world'; diff --git a/packages/koota-solid/src/hooks/use-tag.ts b/packages/koota/src/solid/hooks/use-tag.ts similarity index 98% rename from packages/koota-solid/src/hooks/use-tag.ts rename to packages/koota/src/solid/hooks/use-tag.ts index 1003c1dc..fee239f5 100644 --- a/packages/koota-solid/src/hooks/use-tag.ts +++ b/packages/koota/src/solid/hooks/use-tag.ts @@ -1,4 +1,4 @@ -import { $internal, type Entity, type TagTrait, type World } from 'koota'; +import { $internal, type Entity, type TagTrait, type World } from '../../index'; import { createComputed, createSignal, onCleanup, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/hooks/use-target.ts b/packages/koota/src/solid/hooks/use-target.ts similarity index 97% rename from packages/koota-solid/src/hooks/use-target.ts rename to packages/koota/src/solid/hooks/use-target.ts index 6e1ff87c..be9f07f3 100644 --- a/packages/koota-solid/src/hooks/use-target.ts +++ b/packages/koota/src/solid/hooks/use-target.ts @@ -1,4 +1,4 @@ -import { $internal, type Entity, type Relation, type Trait, type World } from 'koota'; +import { $internal, type Entity, type Relation, type Trait, type World } from '../../index'; import { createComputed, createSignal, onCleanup, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/hooks/use-targets.ts b/packages/koota/src/solid/hooks/use-targets.ts similarity index 98% rename from packages/koota-solid/src/hooks/use-targets.ts rename to packages/koota/src/solid/hooks/use-targets.ts index dc20795b..d0c539be 100644 --- a/packages/koota-solid/src/hooks/use-targets.ts +++ b/packages/koota/src/solid/hooks/use-targets.ts @@ -1,4 +1,4 @@ -import { $internal, type Entity, type Relation, type Trait, type World } from 'koota'; +import { $internal, type Entity, type Relation, type Trait, type World } from '../../index'; import { createComputed, createSignal, onCleanup, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/hooks/use-trait-effect.ts b/packages/koota/src/solid/hooks/use-trait-effect.ts similarity index 97% rename from packages/koota-solid/src/hooks/use-trait-effect.ts rename to packages/koota/src/solid/hooks/use-trait-effect.ts index 69bc5f25..c271f226 100644 --- a/packages/koota-solid/src/hooks/use-trait-effect.ts +++ b/packages/koota/src/solid/hooks/use-trait-effect.ts @@ -1,4 +1,4 @@ -import { $internal, type Entity, type RelationPair, type Trait, type TraitRecord, type World } from 'koota'; +import { $internal, type Entity, type RelationPair, type Trait, type TraitRecord, type World } from '../../index'; import { createEffect, onCleanup, untrack, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, createStableTrait, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/hooks/use-trait.ts b/packages/koota/src/solid/hooks/use-trait.ts similarity index 97% rename from packages/koota-solid/src/hooks/use-trait.ts rename to packages/koota/src/solid/hooks/use-trait.ts index df702a62..17e07bfe 100644 --- a/packages/koota-solid/src/hooks/use-trait.ts +++ b/packages/koota/src/solid/hooks/use-trait.ts @@ -1,4 +1,4 @@ -import { $internal, type Entity, type RelationPair, type Trait, type TraitRecord, type World } from 'koota'; +import { $internal, type Entity, type RelationPair, type Trait, type TraitRecord, type World } from '../../index'; import { createComputed, createSignal, onCleanup, type Accessor } from 'solid-js'; import { isWorld } from '../utils/is-world'; import { access, createStableTrait, type MaybeAccessor } from '../utils/reactive-args'; diff --git a/packages/koota-solid/src/index.ts b/packages/koota/src/solid/index.ts similarity index 100% rename from packages/koota-solid/src/index.ts rename to packages/koota/src/solid/index.ts diff --git a/packages/koota-solid/src/utils/is-world.ts b/packages/koota/src/solid/utils/is-world.ts similarity index 71% rename from packages/koota-solid/src/utils/is-world.ts rename to packages/koota/src/solid/utils/is-world.ts index a34a6047..7d305c84 100644 --- a/packages/koota-solid/src/utils/is-world.ts +++ b/packages/koota/src/solid/utils/is-world.ts @@ -1,4 +1,4 @@ -import type { Entity, World } from 'koota'; +import type { Entity, World } from '../../index'; export function isWorld(target: Entity | World): target is World { return typeof (target as World)?.spawn === 'function'; diff --git a/packages/koota-solid/src/utils/reactive-args.ts b/packages/koota/src/solid/utils/reactive-args.ts similarity index 98% rename from packages/koota-solid/src/utils/reactive-args.ts rename to packages/koota/src/solid/utils/reactive-args.ts index fac29178..a0267688 100644 --- a/packages/koota-solid/src/utils/reactive-args.ts +++ b/packages/koota/src/solid/utils/reactive-args.ts @@ -1,4 +1,4 @@ -import { $internal, $relationPair, type RelationPair, type Trait } from 'koota'; +import { $internal, $relationPair, type RelationPair, type Trait } from '../../index'; import { createMemo, type Accessor } from 'solid-js'; export type MaybeAccessor = T | Accessor; diff --git a/packages/koota-solid/src/world/use-world.ts b/packages/koota/src/solid/world/use-world.ts similarity index 86% rename from packages/koota-solid/src/world/use-world.ts rename to packages/koota/src/solid/world/use-world.ts index ee478762..75c286a6 100644 --- a/packages/koota-solid/src/world/use-world.ts +++ b/packages/koota/src/solid/world/use-world.ts @@ -1,4 +1,4 @@ -import type { World } from 'koota'; +import type { World } from '../../index'; import { useContext } from 'solid-js'; import { WorldContext } from './world-context'; diff --git a/packages/koota-solid/src/world/world-context.ts b/packages/koota/src/solid/world/world-context.ts similarity index 69% rename from packages/koota-solid/src/world/world-context.ts rename to packages/koota/src/solid/world/world-context.ts index bc121174..00aff581 100644 --- a/packages/koota-solid/src/world/world-context.ts +++ b/packages/koota/src/solid/world/world-context.ts @@ -1,4 +1,4 @@ -import type { World } from 'koota'; +import type { World } from '../../index'; import { createContext } from 'solid-js'; export const WorldContext = createContext(); diff --git a/packages/koota-solid/src/world/world-provider.ts b/packages/koota/src/solid/world/world-provider.ts similarity index 89% rename from packages/koota-solid/src/world/world-provider.ts rename to packages/koota/src/solid/world/world-provider.ts index a7d114db..fed3cc09 100644 --- a/packages/koota-solid/src/world/world-provider.ts +++ b/packages/koota/src/solid/world/world-provider.ts @@ -1,4 +1,4 @@ -import type { World } from 'koota'; +import type { World } from '../../index'; import { createComponent, type JSX } from 'solid-js'; import { WorldContext } from './world-context'; diff --git a/packages/koota/src/storage/accessors.ts b/packages/koota/src/storage/accessors.ts new file mode 100644 index 00000000..b949e0ab --- /dev/null +++ b/packages/koota/src/storage/accessors.ts @@ -0,0 +1,137 @@ +import type { Schema } from './types'; + +function createSoASetFunction(schema: Schema) { + const keys = Object.keys(schema); + + // Generate a hardcoded set function based on the schema keys + const setFunctionBody = keys + .map((key) => `if ('${key}' in value) store.${key}[index] = value.${key};`) + .join('\n '); + + // Use new Function to create a set function with hardcoded keys + const set = new Function( + 'index', + 'store', + 'value', + ` + ${setFunctionBody} + ` + ); + + return set; +} + +function createSoAFastSetFunction(schema: Schema) { + const keys = Object.keys(schema); + + // Generate a hardcoded set function based on the schema keys + const setFunctionBody = keys.map((key) => `store.${key}[index] = value.${key};`).join('\n '); + + // Use new Function to create a set function with hardcoded keys + const set = new Function( + 'index', + 'store', + 'value', + ` + ${setFunctionBody} + ` + ); + + return set; +} + +// Return true if any trait value were changed. +function createSoAFastSetChangeFunction(schema: Schema) { + const keys = Object.keys(schema); + + // Generate a hardcoded set function based on the schema keys + const setFunctionBody = keys + .map( + (key) => + `if (store.${key}[index] !== value.${key}) { + store.${key}[index] = value.${key}; + changed = true; + }` + ) + .join('\n '); + + // Use new Function to create a set function with hardcoded keys + const set = new Function( + 'index', + 'store', + 'value', + ` + let changed = false; + ${setFunctionBody} + return changed; + ` + ); + + return set; +} + +function createSoAGetFunction(schema: Schema) { + const keys = Object.keys(schema); + + // Create an object literal with all keys assigned from the store + const objectLiteral = `{ ${keys.map((key) => `${key}: store.${key}[index]`).join(', ')} }`; + + // Use new Function to create a get function that returns the pre-populated object + const get = new Function( + 'index', + 'store', + ` + return ${objectLiteral}; + ` + ); + + return get; +} + +function createAoSSetFunction(_schema: Schema) { + return (index: number, store: any, value: any) => { + store[index] = value; + }; +} + +function createAoSFastSetChangeFunction(_schema: Schema) { + return (index: number, store: any, value: any) => { + let changed = false; + if (value !== store[index]) { + store[index] = value; + changed = true; + } + return changed; + }; +} + +function createAoSGetFunction(_schema: Schema) { + return (index: number, store: any) => store[index]; +} + +const noop = () => {}; +const createTagNoop = () => noop; + +export const createSetFunction = { + soa: createSoASetFunction, + aos: createAoSSetFunction, + tag: createTagNoop, +}; + +export const createFastSetFunction = { + soa: createSoAFastSetFunction, + aos: createAoSSetFunction, + tag: createTagNoop, +}; + +export const createFastSetChangeFunction = { + soa: createSoAFastSetChangeFunction, + aos: createAoSFastSetChangeFunction, + tag: createTagNoop, +}; + +export const createGetFunction = { + soa: createSoAGetFunction, + aos: createAoSGetFunction, + tag: createTagNoop, +}; diff --git a/packages/koota/src/storage/index.ts b/packages/koota/src/storage/index.ts new file mode 100644 index 00000000..2b36d07d --- /dev/null +++ b/packages/koota/src/storage/index.ts @@ -0,0 +1,11 @@ +export { createStore } from './stores'; +export { + createSetFunction, + createFastSetFunction, + createFastSetChangeFunction, + createGetFunction, +} from './accessors'; + +export { validateSchema, getSchemaDefaults } from './schema'; + +export type { Store, StoreType, Schema, AoSFactory, Norm } from './types'; diff --git a/packages/koota/src/storage/schema.ts b/packages/koota/src/storage/schema.ts new file mode 100644 index 00000000..2c436906 --- /dev/null +++ b/packages/koota/src/storage/schema.ts @@ -0,0 +1,36 @@ +import type { Schema, StoreType } from './types'; + +/** + * Get default values from a schema. + * Returns null for tags (empty schemas) or if no defaults exist. + */ +/* @inline @pure */ export function getSchemaDefaults( + schema: Record | (() => unknown), + type: StoreType +): Record | null { + if (type === 'aos') { + return typeof schema === 'function' ? (schema() as Record) : null; + } + + if (!schema || typeof schema === 'function' || Object.keys(schema).length === 0) return null; + + const defaults: Record = {}; + for (const key in schema) { + if (typeof schema[key] === 'function') { + defaults[key] = schema[key](); + } else { + defaults[key] = schema[key]; + } + } + return defaults; +} + +export /* @inline @pure */ function validateSchema(schema: Schema) { + for (const key in schema) { + const value = schema[key as keyof Schema]; + if (value !== null && typeof value === 'object') { + const kind = Array.isArray(value) ? 'array' : 'object'; + throw new Error(`Koota: ${key} is an ${kind}, which is not supported in traits.`); + } + } +} diff --git a/packages/koota/src/storage/stores.ts b/packages/koota/src/storage/stores.ts new file mode 100644 index 00000000..fe92adde --- /dev/null +++ b/packages/koota/src/storage/stores.ts @@ -0,0 +1,17 @@ +import type { Schema } from './types'; +import type { Store } from './types'; + +export function createStore(schema: T): Store; +export function createStore(schema: Schema): unknown { + if (typeof schema === 'function') { + return []; + } else { + const store: Record = {}; + + for (const key in schema) { + store[key] = []; + } + + return store; + } +} diff --git a/packages/koota/src/storage/types.ts b/packages/koota/src/storage/types.ts new file mode 100644 index 00000000..d32c087e --- /dev/null +++ b/packages/koota/src/storage/types.ts @@ -0,0 +1,65 @@ +/** + * Storage type for trait data. + * - AoS: Array of instances, one per entity + * - SoA: Object with arrays, one array per property + */ +export type Store = T extends AoSFactory + ? ReturnType[] + : { + [P in keyof T]: T[P] extends (...args: never[]) => unknown ? ReturnType[] : T[P][]; + }; + +/** + * Storage layout type. + * - 'soa': Struct of Arrays - properties stored in separate arrays + * - 'aos': Array of Structs - instances stored directly + * - 'tag': No data storage - empty schema marker + */ +export type StoreType = 'aos' | 'soa' | 'tag'; + +/** + * Schema definition for traits. + * Can be a SoA object schema, an AoS factory function, or an empty object (tag). + */ +export type Schema = + | { + [key: string]: number | bigint | string | boolean | null | undefined | (() => unknown); + } + | AoSFactory + | Record; + +/** + * Factory function for AoS (Array of Structs) storage. + * Returns a single instance that will be stored per entity. + */ +export type AoSFactory = () => unknown; + +/** + * Normalizes schema types to their primitive forms. + * Ensures that explicit values like true, false or "string literal" are + * normalized to their primitive types (boolean, string, etc). + */ +export type Norm = + T extends Record + ? T + : T extends AoSFactory + ? () => ReturnType extends number + ? number + : ReturnType extends boolean + ? boolean + : ReturnType extends string + ? string + : ReturnType + : { + [K in keyof T]: T[K] extends object + ? T[K] extends (...args: never[]) => unknown + ? T[K] + : never + : T[K] extends boolean + ? boolean + : T[K]; + }[keyof T] extends never + ? never + : { + [K in keyof T]: T[K] extends boolean ? boolean : T[K]; + }; diff --git a/packages/koota/src/trait/trait-instance.ts b/packages/koota/src/trait/trait-instance.ts new file mode 100644 index 00000000..aacca66d --- /dev/null +++ b/packages/koota/src/trait/trait-instance.ts @@ -0,0 +1,47 @@ +import type { Trait, TraitInstance } from './types'; + +export type TraitInstanceArray = (TraitInstance | undefined)[]; + +/** + * Get TraitInstance by trait ID + */ +export /* @inline @pure */ function getTraitInstance( + traitData: TraitInstanceArray, + trait: Trait +): TraitInstance | undefined { + return traitData[trait.id]; +} + +/** + * Set TraitInstance by trait ID + */ +export /* @inline */ function setTraitInstance( + traitData: TraitInstanceArray, + trait: Trait, + data: TraitInstance +): void { + const traitId = trait.id; + // Ensure array is large enough + if (traitId >= traitData.length) { + traitData.length = traitId + 1; + } + traitData[traitId] = data; +} + +/** + * Check if trait is registered + */ +export /* @inline @pure */ function hasTraitInstance( + traitData: TraitInstanceArray, + trait: Trait +): boolean { + const traitId = trait.id; + return traitId < traitData.length && traitData[traitId] !== undefined; +} + +/** + * Clear all trait data + */ +export /* @inline */ function clearTraitInstance(traitData: TraitInstanceArray): void { + traitData.length = 0; +} diff --git a/packages/koota/src/trait/trait.ts b/packages/koota/src/trait/trait.ts new file mode 100644 index 00000000..2083d2f8 --- /dev/null +++ b/packages/koota/src/trait/trait.ts @@ -0,0 +1,523 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import { getEntityId } from '../entity/utils/pack-entity'; +import { setChanged, setPairChanged } from '../query/modifiers/changed'; +import { checkQueryTrackingWithRelations } from '../query/utils/check-query-tracking-with-relations'; +import { checkQueryWithRelations } from '../query/utils/check-query-with-relations'; +import { getOrderedTraitRelation, isOrderedTrait, setupOrderedTraitSync } from '../relation/ordered'; +import { OrderedList } from '../relation/ordered-list'; +import { + addRelationTarget, + getFirstRelationTarget, + getRelationData, + getRelationTargets, + hasRelationPair, + hasRelationToTarget, + removeAllRelationTargets, + removeRelationTarget, + setRelationData, + setRelationDataAtIndex, +} from '../relation/relation'; +import type { OrderedRelation, Relation, RelationPair } from '../relation/types'; +import { isRelationPair } from '../relation/utils/is-relation'; +import { + createFastSetChangeFunction, + createFastSetFunction, + createGetFunction, + createSetFunction, + createStore, + getSchemaDefaults, + type Norm, + type Schema, + type StoreType, + validateSchema, +} from '../storage'; +import type { World } from '../world'; +import { incrementWorldBitflag } from '../world/utils/increment-world-bit-flag'; +import { getTraitInstance, hasTraitInstance, setTraitInstance } from './trait-instance'; +import type { + ConfigurableTrait, + ExtractStore, + TagTrait, + Trait, + TraitInstance, + TraitValue, +} from './types'; + +// No reason to create a new object every time a tag trait is created. +const tagSchema = Object.freeze({}); +let traitId = 0; + +function createTrait(schema?: undefined | Record): TagTrait; +function createTrait(schema: S): Trait>; +function createTrait(schema: S = tagSchema as S): Trait> { + const isAoS = typeof schema === 'function'; + const isTag = !isAoS && Object.keys(schema).length === 0; + const traitType: StoreType = isAoS ? 'aos' : isTag ? 'tag' : 'soa'; + + validateSchema(schema); + + const id = traitId++; + const Trait = Object.assign((params: TraitValue>) => [Trait, params], { + [$internal]: { + id: id, + set: createSetFunction[traitType](schema), + fastSet: createFastSetFunction[traitType](schema), + fastSetWithChangeDetection: createFastSetChangeFunction[traitType](schema), + get: createGetFunction[traitType](schema), + createStore: () => createStore(schema), + relation: null, + type: traitType, + }, + }) as Trait>; + + // Add public read-only properties + Object.defineProperty(Trait, 'id', { + value: id, + writable: false, + enumerable: true, + configurable: false, + }); + + Object.defineProperty(Trait, 'schema', { + value: schema, + writable: false, + enumerable: true, + configurable: false, + }); + + return Trait; +} + +export const trait = createTrait; + +export function registerTrait(world: World, trait: Trait) { + const ctx = world[$internal]; + const traitCtx = trait[$internal]; + + const data: TraitInstance = { + generationId: ctx.entityMasks.length - 1, + bitflag: ctx.bitflag, + trait, + store: traitCtx.createStore(), + queries: new Set(), + trackingQueries: new Set(), + notQueries: new Set(), + relationQueries: new Set(), + schema: trait.schema, + changeSubscriptions: new Set(), + addSubscriptions: new Set(), + removeSubscriptions: new Set(), + }; + + // Add trait to the world. + setTraitInstance(ctx.traitInstances, trait, data); + world.traits.add(trait); + + // Track relations + if (traitCtx.relation) ctx.relations.add(traitCtx.relation); + + // This ensures nested trait registrations get different bitflags. + incrementWorldBitflag(world); + + // Setup ordered trait sync if this is an ordered trait + if (isOrderedTrait(trait)) setupOrderedTraitSync(world, trait); +} + +function getOrderedTrait(world: World, entity: Entity, trait: OrderedRelation): OrderedList { + const relation = getOrderedTraitRelation(trait); + return new OrderedList(world, entity, relation, trait); +} + +export function addTrait(world: World, entity: Entity, ...traits: ConfigurableTrait[]) { + for (let i = 0; i < traits.length; i++) { + const config = traits[i]; + + // Handle relation pairs + if (isRelationPair(config)) { + addRelationPair(world, entity, config); + continue; + } + + // Get trait and params for regular traits + let trait: Trait; + let params: Record | undefined; + + if (Array.isArray(config)) { + [trait, params] = config as [Trait, Record]; + } else { + trait = config as Trait; + } + + // Add the trait to the entity + const data = addTraitToEntity(world, entity, trait); + if (!data) continue; // Already had the trait + + // Initialize values + const traitCtx = trait[$internal]; + + const defaults = isOrderedTrait(trait) + ? getOrderedTrait(world, entity, trait) + : getSchemaDefaults(data.schema, traitCtx.type); + + if (traitCtx.type === 'aos') { + setTrait(world, entity, trait, params ?? defaults, false); + } else if (defaults) { + setTrait(world, entity, trait, { ...defaults, ...params }, false); + } else if (params) { + setTrait(world, entity, trait, params, false); + } + + // Call add subscriptions after values are set + for (const sub of data.addSubscriptions) sub(entity); + } +} + +/** + * Add a relation pair to an entity. + */ +/* @inline */ function addRelationPair(world: World, entity: Entity, pair: RelationPair) { + const relation = pair.relation; + const target = pair.target; + + // Only specific targets can be added (not wildcard '*') + if (typeof target !== 'number') return; + + const params = pair.params; + const relationCtx = relation[$internal]; + const relationTrait = relationCtx.trait; + + // Ignore if entity already relates to this target + // For example, adding Likes(alice) when this pair is already on the entity. + if (hasRelationToTarget(world, relation, entity, target)) return; + + // For exclusive relations, remove the old target first + if (relationCtx.exclusive) { + const oldTarget = getFirstRelationTarget(world, relation, entity); + if (oldTarget !== undefined && oldTarget !== target) { + const instance = getTraitInstance(world[$internal].traitInstances, relationTrait); + if (instance) { + for (const sub of instance.removeSubscriptions) sub(entity, oldTarget); + } + removeRelationTarget(world, relation, entity, oldTarget); + } + } + + let instance = addTraitToEntity(world, entity, relationTrait); + + const targetIndex = addRelationTarget(world, relation, entity, target); + if (targetIndex === -1) return; // No-op + + const schema = + instance?.schema ?? getTraitInstance(world[$internal].traitInstances, relationTrait)!.schema; + const defaults = getSchemaDefaults(schema, relationTrait[$internal].type); + + if (defaults) { + setRelationDataAtIndex(world, entity, relation, targetIndex, { ...defaults, ...params }); + } else if (params) { + setRelationDataAtIndex(world, entity, relation, targetIndex, params); + } + + // Fire add subscription for this pair + instance = instance ?? getTraitInstance(world[$internal].traitInstances, relationTrait)!; + for (const sub of instance.addSubscriptions) sub(entity, target); +} + +export function removeTrait(world: World, entity: Entity, ...traits: (Trait | RelationPair)[]) { + for (let i = 0; i < traits.length; i++) { + const trait = traits[i]; + + if (isRelationPair(trait)) { + removeRelationPair(world, entity, trait); + continue; + } + + if (!hasTrait(world, entity, trait)) continue; + + const traitCtx = trait[$internal]; + + if (traitCtx.relation) { + // Relation trait: emit per-pair removes, then teardown + const instance = getTraitInstance(world[$internal].traitInstances, trait); + if (instance) { + const targets = getRelationTargets(world, traitCtx.relation, entity); + for (const t of targets) { + for (const sub of instance.removeSubscriptions) sub(entity, t); + } + } + removeAllRelationTargets(world, traitCtx.relation, entity); + } else { + // Regular trait: emit generic remove + const instance = getTraitInstance(world[$internal].traitInstances, trait); + if (instance) { + for (const sub of instance.removeSubscriptions) sub(entity); + } + } + + removeTraitFromEntity(world, entity, trait); + } +} + +/* @inline */ function removeRelationPair(world: World, entity: Entity, pair: RelationPair) { + const relation = pair.relation; + const target = pair.target; + const relationTrait = relation[$internal].trait; + + if (!hasTrait(world, entity, relationTrait)) return; + + const instance = getTraitInstance(world[$internal].traitInstances, relationTrait); + + if (target === '*') { + if (instance) { + const targets = getRelationTargets(world, relation, entity); + for (const t of targets) { + for (const sub of instance.removeSubscriptions) sub(entity, t); + } + } + removeAllRelationTargets(world, relation, entity); + removeTraitFromEntity(world, entity, relationTrait); + return; + } + + if (typeof target === 'number') { + if (instance) { + for (const sub of instance.removeSubscriptions) sub(entity, target); + } + + const { removedIndex, wasLastTarget } = removeRelationTarget(world, relation, entity, target); + if (removedIndex === -1) return; + + if (wasLastTarget) removeTraitFromEntity(world, entity, relationTrait); + } +} + +/** + * Remove a relation target and clean up the base trait if it was the last target. + * This is used by entity destruction to ensure proper cleanup. + */ +export function cleanupRelationTarget( + world: World, + relation: Relation, + entity: Entity, + target: Entity +): void { + const relationTrait = relation[$internal].trait; + + const instance = getTraitInstance(world[$internal].traitInstances, relationTrait); + if (instance) { + for (const sub of instance.removeSubscriptions) sub(entity, target); + } + + const { removedIndex, wasLastTarget } = removeRelationTarget(world, relation, entity, target); + if (removedIndex === -1) return; + + if (wasLastTarget) removeTraitFromEntity(world, entity, relationTrait); +} + +export function hasTrait(world: World, entity: Entity, trait: Trait): boolean { + const ctx = world[$internal]; + const instance = getTraitInstance(ctx.traitInstances, trait); + if (!instance) return false; + + const { generationId, bitflag } = instance; + const eid = getEntityId(entity); + const mask = ctx.entityMasks[generationId][eid]; + + return (mask & bitflag) === bitflag; +} + +export /* @inline @pure */ function getStore( + world: World, + trait: C +): ExtractStore { + const ctx = world[$internal]; + const instance = getTraitInstance(ctx.traitInstances, trait)!; + return instance.store as ExtractStore; +} + +export function setTrait( + world: World, + entity: Entity, + trait: Trait | RelationPair, + value: any, + triggerChanged = true +) { + if (isRelationPair(trait)) return setTraitForPair(world, entity, trait, value, triggerChanged); + return setTraitForTrait(world, entity, trait, value, triggerChanged); +} + +export function getTrait(world: World, entity: Entity, trait: Trait | RelationPair) { + if (isRelationPair(trait)) return getTraitForPair(world, entity, trait); + return getTraitForTrait(world, entity, trait); +} + +/** + * Get trait data for a relation pair. + */ +/* @inline @pure */ function getTraitForPair(world: World, entity: Entity, pair: RelationPair) { + const relation = pair.relation as Relation; + const target = pair.target; + + if (!hasRelationPair(world, entity, pair)) return undefined; + if (typeof target !== 'number') return undefined; + + return getRelationData(world, entity, relation, target); +} + +/** + * Get trait data for a regular trait. + */ +/* @inline @pure */ function getTraitForTrait(world: World, entity: Entity, trait: Trait) { + if (!hasTrait(world, entity, trait)) return undefined; + + const traitCtx = trait[$internal]; + const store = getStore(world, trait); + const data = traitCtx.get(getEntityId(entity), store); + + return data; +} + +/** + * Set trait data for a relation pair. + */ +/* @inline */ function setTraitForPair( + world: World, + entity: Entity, + pair: RelationPair, + value: any, + triggerChanged: boolean +) { + const relation = pair.relation as Relation; + const target = pair.target; + + if (typeof target !== 'number') return; + + setRelationData(world, entity, relation, target, value); + if (triggerChanged) setPairChanged(world, entity, relation[$internal].trait, target); +} + +/** + * Set trait data for a regular trait. + */ +/* @inline */ function setTraitForTrait( + world: World, + entity: Entity, + trait: Trait, + value: any, + triggerChanged: boolean +) { + const ctx = trait[$internal]; + const store = getStore(world, trait); + const index = getEntityId(entity); + + // A short circuit is more performance than an if statement which creates a new code statement. + value instanceof Function && (value = value(ctx.get(index, store))); + + ctx.set(index, store, value); + triggerChanged && setChanged(world, entity, trait); +} + +/** + * Core logic for adding a trait to an entity. + */ +/* @inline */ function addTraitToEntity( + world: World, + entity: Entity, + trait: Trait +): TraitInstance | undefined { + // Exit early if the entity already has the trait + if (hasTrait(world, entity, trait)) return undefined; + + const ctx = world[$internal]; + + // Register the trait if it's not already registered + if (!hasTraitInstance(ctx.traitInstances, trait)) registerTrait(world, trait); + + const instance = getTraitInstance(ctx.traitInstances, trait)!; + const { generationId, bitflag, queries, trackingQueries } = instance; + + // Add bitflag to entity bitmask + const eid = getEntityId(entity); + ctx.entityMasks[generationId][eid] |= bitflag; + + // Set the entity as dirty + for (const dirtyMask of ctx.dirtyMasks.values()) { + if (!dirtyMask[generationId]) dirtyMask[generationId] = []; + dirtyMask[generationId][eid] |= bitflag; + } + + // Update non-tracking queries (no event data needed) + for (const query of queries) { + query.toRemove.remove(entity); + // Use checkQueryWithRelations if query has relation filters, otherwise use checkQuery + const match = + query.relationFilters && query.relationFilters.length > 0 + ? checkQueryWithRelations(world, query, entity) + : query.check(world, entity); + if (match) query.add(entity); + else query.remove(world, entity); + } + + // Update tracking queries (with event data) + for (const query of trackingQueries) { + query.toRemove.remove(entity); + // Use checkQueryTrackingWithRelations if query has relation filters, otherwise use checkQueryTracking + const match = + query.relationFilters && query.relationFilters.length > 0 + ? checkQueryTrackingWithRelations(world, query, entity, 'add', generationId, bitflag) + : query.checkTracking(world, entity, 'add', generationId, bitflag); + if (match) query.add(entity); + else query.remove(world, entity); + } + + // Add trait to entity internally + ctx.entityTraits.get(entity)!.add(trait); + + return instance; +} + +/** + * Core logic for removing a trait from an entity. + * Does not emit remove subscriptions — callers handle emission. + */ +function removeTraitFromEntity(world: World, entity: Entity, trait: Trait): void { + if (!hasTrait(world, entity, trait)) return; + + const ctx = world[$internal]; + const instance = getTraitInstance(ctx.traitInstances, trait)!; + const { generationId, bitflag, queries, trackingQueries } = instance; + + // Remove bitflag from entity bitmask + const eid = getEntityId(entity); + ctx.entityMasks[generationId][eid] &= ~bitflag; + + // Set the entity as dirty + for (const dirtyMask of ctx.dirtyMasks.values()) { + dirtyMask[generationId][eid] |= bitflag; + } + + // Update non-tracking queries + for (const query of queries) { + // Use checkQueryWithRelations if query has relation filters, otherwise use checkQuery + const match = + query.relationFilters && query.relationFilters.length > 0 + ? checkQueryWithRelations(world, query, entity) + : query.check(world, entity); + if (match) query.add(entity); + else query.remove(world, entity); + } + + // Update tracking queries (with event data) + for (const query of trackingQueries) { + // Use checkQueryTrackingWithRelations if query has relation filters, otherwise use checkQueryTracking + const match = + query.relationFilters && query.relationFilters.length > 0 + ? checkQueryTrackingWithRelations(world, query, entity, 'remove', generationId, bitflag) + : query.checkTracking(world, entity, 'remove', generationId, bitflag); + if (match) query.add(entity); + else query.remove(world, entity); + } + + // Remove trait from entity internally + ctx.entityTraits.get(entity)!.delete(trait); +} diff --git a/packages/koota/src/trait/types.ts b/packages/koota/src/trait/types.ts new file mode 100644 index 00000000..c666a8bc --- /dev/null +++ b/packages/koota/src/trait/types.ts @@ -0,0 +1,112 @@ +import { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import type { QueryInstance } from '../query/types'; +import type { Relation, RelationPair } from '../relation/types'; +import type { AoSFactory, Schema, Store, StoreType } from '../storage'; + +// Backwards-compatible alias (the trait "type" is the storage layout). +export type TraitType = StoreType; + +export type TraitValue = TSchema extends AoSFactory + ? ReturnType + : Partial>; + +export type Trait = { + /** Public read-only ID for fast array lookups */ + readonly id: number; + readonly schema: TSchema; + [$internal]: { + set: (index: number, store: any, value: TraitValue) => void; + fastSet: (index: number, store: any, value: TraitValue) => boolean; + fastSetWithChangeDetection: (index: number, store: any, value: TraitValue) => boolean; + get: (index: number, store: any) => TraitRecord; + id: number; + createStore: () => Store; + /** Reference to parent relation if this trait is owned by a relation */ + relation: Relation | null; + type: StoreType; + }; +} & ((params?: TraitValue) => [Trait, TraitValue]); + +export type TagTrait = Trait> & { [$internal]: { type: 'tag' } }; + +export type TraitTuple = [ + T, + T extends Trait ? (S extends AoSFactory ? ReturnType : Partial>) : never, +]; + +export type ConfigurableTrait = T | TraitTuple | RelationPair; + +export type SetTraitCallback = ( + prev: TraitRecord> +) => TraitValue>; + +type TraitRecordFromSchema = T extends AoSFactory + ? ReturnType + : { + [P in keyof T]: T[P] extends (...args: never[]) => unknown ? ReturnType : T[P]; + }; + +/** + * The record of a trait. + * For SoA it is a snapshot of the state for a single entity. + * For AoS it is the state instance for a single entity. + */ +export type TraitRecord = T extends Trait + ? TraitRecordFromSchema + : TraitRecordFromSchema; + +// Type Utils + +export type ExtractSchema | RelationPair> = + T extends RelationPair + ? ExtractSchema + : T extends Relation + ? ExtractSchema + : T extends Trait + ? S + : never; +export type ExtractStore = T extends { [$internal]: { createStore(): infer Store } } + ? Store + : never; +export type ExtractIsTag = T extends { [$internal]: { type: 'tag' } } ? true : false; + +export type IsTag = ExtractIsTag; + +export interface TraitInstance> { + generationId: number; + bitflag: number; + trait: Trait; + store: Store; + /** Non-tracking queries that include this trait */ + queries: Set; + /** Tracking queries (Added/Removed/Changed) that include this trait */ + trackingQueries: Set; + notQueries: Set; + /** Queries that filter by this relation (only for relation traits) */ + relationQueries: Set; + schema: S; + changeSubscriptions: Set<(entity: Entity, target?: Entity) => void>; + addSubscriptions: Set<(entity: Entity, target?: Entity) => void>; + removeSubscriptions: Set<(entity: Entity, target?: Entity) => void>; + /** + * Only for relation traits. + * For exclusive: relationTargets[eid] = targetId (number) + * For non-exclusive: relationTargets[eid] = [targetId1, targetId2, ...] (number[]) + */ + relationTargets?: number[] | number[][]; + /** Reverse index: target entity ID → source entities pointing at it. */ + relationSourcesByTarget?: Entity[][]; + /** Bumped whenever the source bucket of a target changes, indexed by target entity ID. */ + relationSourceVersions?: number[]; +} + +export type TraitOrRelation = Trait | Relation; + +/** Extracts the underlying Trait from a TraitOrRelation (Relations contain a Trait) */ +export type ExtractTrait = T extends Relation ? TTrait : T; + +/** Maps a tuple of TraitOrRelation to their underlying Traits */ +export type ExtractTraits = { + [K in keyof T]: ExtractTrait; +}; diff --git a/packages/koota/src/universe/universe.ts b/packages/koota/src/universe/universe.ts new file mode 100644 index 00000000..ddefd4d3 --- /dev/null +++ b/packages/koota/src/universe/universe.ts @@ -0,0 +1,14 @@ +import type { Query } from '../query/types'; +import type { World } from '../world'; +import { createWorldIndex } from '../world/utils/world-index'; + +export const universe = { + worlds: [] as (World | null)[], + cachedQueries: new Map>(), + worldIndex: createWorldIndex(), + reset: () => { + universe.worlds = []; + universe.cachedQueries = new Map(); + universe.worldIndex = createWorldIndex(); + }, +}; diff --git a/packages/koota/src/utils/shallow-equal.ts b/packages/koota/src/utils/shallow-equal.ts new file mode 100644 index 00000000..b325ab65 --- /dev/null +++ b/packages/koota/src/utils/shallow-equal.ts @@ -0,0 +1,20 @@ +// This shallow equal looks insane because it is optimized to use short circuiting +// and the least amount of evaluations possible. + +export function /* @inline @pure */ shallowEqual(obj1: any, obj2: any): boolean { + return ( + obj1 === obj2 || + (typeof obj1 === 'object' && + obj1 !== null && + typeof obj2 === 'object' && + obj2 !== null && + (() => { + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + return ( + keys1.length === keys2.length && + keys1.every((key) => Object.hasOwn(obj2, key) && obj1[key] === obj2[key]) + ); + })()) + ); +} diff --git a/packages/koota/src/utils/types.ts b/packages/koota/src/utils/types.ts new file mode 100644 index 00000000..acace559 --- /dev/null +++ b/packages/koota/src/utils/types.ts @@ -0,0 +1,2 @@ +export type Constructor = new (...args: any[]) => T; +export type IsEmpty = T extends Record ? true : false; diff --git a/packages/koota/src/world/index.ts b/packages/koota/src/world/index.ts new file mode 100644 index 00000000..b5082e01 --- /dev/null +++ b/packages/koota/src/world/index.ts @@ -0,0 +1,2 @@ +export { createWorld } from './world'; +export type { World, WorldOptions, WorldInternal } from './types'; diff --git a/packages/koota/src/world/types.ts b/packages/koota/src/world/types.ts new file mode 100644 index 00000000..4de9f060 --- /dev/null +++ b/packages/koota/src/world/types.ts @@ -0,0 +1,126 @@ +import type { ActionInstance } from '../actions/types'; +import type { $internal } from '../common'; +import type { Entity } from '../entity/types'; +import type { createEntityIndex } from '../entity/utils/entity-index'; +import type { + Query, + QueryInstance, + QueryParameter, + QueryResult, + QueryUnsubscriber, + SortCache, +} from '../query/types'; +import type { Relation, RelationPair } from '../relation/types'; +import type { + ConfigurableTrait, + ExtractSchema, + SetTraitCallback, + Trait, + TraitInstance, + TraitRecord, + TraitValue, +} from '../trait/types'; + +export type WorldOptions = { + traits?: ConfigurableTrait[]; + lazy?: boolean; +}; + +export type WorldInternal = { + entityIndex: ReturnType; + entityMasks: number[][]; + entityTraits: Map>; + bitflag: number; + traitInstances: (TraitInstance | undefined)[]; + relations: Set>; + queriesHashMap: Map; + queryInstances: (QueryInstance | undefined)[]; + actionInstances: (ActionInstance | undefined)[]; + notQueries: Set; + dirtyQueries: Set; + dirtyMasks: Map; + trackingSnapshots: Map; + changedMasks: Map; + worldEntity: Entity; + trackedTraits: Set; + resetSubscriptions: Set<(world: World) => void>; + sortCaches: Map; +}; + +export type World = { + readonly id: number; + readonly isInitialized: boolean; + readonly entities: Entity[]; + readonly traits: Set; + [$internal]: WorldInternal; + init(...traits: ConfigurableTrait[]): void; + spawn(...traits: ConfigurableTrait[]): Entity; + has(entity: Entity): boolean; + has(trait: Trait): boolean; + has(target: Entity | Trait): boolean; + add(...traits: ConfigurableTrait[]): void; + remove(...traits: Trait[]): void; + get(trait: T): TraitRecord> | undefined; + set(trait: T, value: TraitValue> | SetTraitCallback): void; + destroy(): void; + reset(): void; + query(key: Query): QueryResult; + query(...parameters: T): QueryResult; + queryFirst(key: Query): Entity | undefined; + queryFirst(...parameters: T): Entity | undefined; + onQueryAdd( + key: Query, + callback: (entity: Entity) => void + ): QueryUnsubscriber; + onQueryAdd( + parameters: T, + callback: (entity: Entity) => void + ): QueryUnsubscriber; + onQueryRemove( + key: Query, + callback: (entity: Entity) => void + ): QueryUnsubscriber; + onQueryRemove( + parameters: T, + callback: (entity: Entity) => void + ): QueryUnsubscriber; + onAdd(trait: T, callback: (entity: Entity) => void): QueryUnsubscriber; + onAdd( + relation: Relation, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onAdd( + pair: RelationPair, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onAdd( + input: Trait | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ): QueryUnsubscriber; + onRemove(trait: T, callback: (entity: Entity) => void): QueryUnsubscriber; + onRemove( + relation: Relation, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onRemove( + pair: RelationPair, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onRemove( + input: Trait | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ): QueryUnsubscriber; + onChange(trait: T, callback: (entity: Entity) => void): QueryUnsubscriber; + onChange( + relation: Relation, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onChange( + pair: RelationPair, + callback: (entity: Entity, target: Entity) => void + ): QueryUnsubscriber; + onChange( + input: Trait | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ): QueryUnsubscriber; +}; diff --git a/packages/koota/src/world/utils/increment-world-bit-flag.ts b/packages/koota/src/world/utils/increment-world-bit-flag.ts new file mode 100644 index 00000000..10cd0458 --- /dev/null +++ b/packages/koota/src/world/utils/increment-world-bit-flag.ts @@ -0,0 +1,15 @@ +import { $internal } from '../../common'; +import type { World } from '../types'; + +// These should be Float32Arrays since we are using bitwise operations. +// They are native Arrays to avoid overlow issues due to recycling. +export /* @inline */ function incrementWorldBitflag(world: World) { + const ctx = world[$internal]; + + ctx.bitflag *= 2; + + if (ctx.bitflag >= 2 ** 31) { + ctx.bitflag = 1; + ctx.entityMasks.push([]); + } +} diff --git a/packages/koota/src/world/utils/world-index.ts b/packages/koota/src/world/utils/world-index.ts new file mode 100644 index 00000000..dfb6ec61 --- /dev/null +++ b/packages/koota/src/world/utils/world-index.ts @@ -0,0 +1,55 @@ +import { WORLD_ID_BITS } from '../../entity/utils/pack-entity'; + +export type WorldIndex = { + worldCursor: number; + releasedWorldIds: number[]; + maxWorlds: number; +}; + +/** + * Creates and initializes a new WorldIndex. + * @param worldIdBits - The number of bits used for world IDs. + * @returns A new WorldIndex object. + */ +export function createWorldIndex(): WorldIndex { + return { + worldCursor: 0, + releasedWorldIds: [], + maxWorlds: 2 ** WORLD_ID_BITS, + }; +} + +/** + * Allocates a new world ID or recycles an existing one. + * @param index - The WorldIndex to allocate from. + * @returns The new or recycled world ID. + */ +export function allocateWorldId(index: WorldIndex): number { + if (index.releasedWorldIds.length > 0) { + return index.releasedWorldIds.pop()!; + } + + if (index.worldCursor >= index.maxWorlds) { + throw new Error(`Koota: Too many worlds created. The maximum is ${index.maxWorlds}.`); + } + return index.worldCursor++; +} + +/** + * Releases a world ID back to the index. + * @param index - The WorldIndex to release to. + * @param worldId - The world ID to release. + */ +export function releaseWorldId(index: WorldIndex, worldId: number): void { + if (worldId < 0 || worldId >= index.maxWorlds) { + throw new Error(`Invalid world ID: ${worldId}`); + } + + if (worldId === index.worldCursor - 1) { + // If it's the last allocated ID, just decrement the cursor + index.worldCursor--; + } else if (worldId < index.worldCursor && !index.releasedWorldIds.includes(worldId)) { + // Otherwise, add it to the released IDs list for recycling + index.releasedWorldIds.push(worldId); + } +} diff --git a/packages/koota/src/world/world.ts b/packages/koota/src/world/world.ts new file mode 100644 index 00000000..d82710dd --- /dev/null +++ b/packages/koota/src/world/world.ts @@ -0,0 +1,437 @@ +import { $internal } from '../common'; +import { createEntity, destroyEntity } from '../entity/entity'; +import type { Entity } from '../entity/types'; +import { createEntityIndex, getAliveEntities, isEntityAlive } from '../entity/utils/entity-index'; +import { IsExcluded, createQuery, createQueryInstance } from '../query/query'; +import { createRelationOnlyQueryResult } from '../query/query-result'; +import type { Query, QueryInstance, QueryParameter, QueryUnsubscriber } from '../query/types'; +import { createQueryHash } from '../query/utils/create-query-hash'; +import { isQuery } from '../query/utils/is-query'; +import { getTrackingCursor, setTrackingMasks } from '../query/utils/tracking-cursor'; +import { getEntitiesWithRelationTo } from '../relation/relation'; +import type { Relation, RelationPair } from '../relation/types'; +import { isRelation, isRelationPair } from '../relation/utils/is-relation'; +import { addTrait, getTrait, hasTrait, registerTrait, removeTrait, setTrait } from '../trait/trait'; +import { clearTraitInstance, getTraitInstance, hasTraitInstance } from '../trait/trait-instance'; +import type { + ConfigurableTrait, + ExtractSchema, + SetTraitCallback, + Trait, + TraitRecord, + TraitValue, +} from '../trait/types'; +import { universe } from '../universe/universe'; +import type { World, WorldInternal, WorldOptions } from './types'; +import { allocateWorldId, releaseWorldId } from './utils/world-index'; + +export function createWorld(options: WorldOptions): World; +export function createWorld(...traits: ConfigurableTrait[]): World; +export function createWorld( + optionsOrFirstTrait?: WorldOptions | ConfigurableTrait, + ...traits: ConfigurableTrait[] +): World { + const id = allocateWorldId(universe.worldIndex); + let isInitialized = false; + let lazyTraits: ConfigurableTrait[] | undefined; + type HookInput = Trait | Relation | RelationPair; + type HookCallback = (entity: Entity, target?: Entity) => void; + + function resolveHookTrait(input: HookInput): Trait { + if (isRelationPair(input)) return input.relation[$internal].trait; + if (isRelation(input)) return input[$internal].trait; + return input; + } + + function resolveHookCallback(input: HookInput, callback: HookCallback): HookCallback { + if (isRelationPair(input)) { + const pairTargetQuery = input.targetQuery; + if (pairTargetQuery) { + const targetQuery = isQuery(pairTargetQuery) + ? pairTargetQuery + : createQuery(...pairTargetQuery); + + return (entity: Entity, target?: Entity) => { + /** + * @todo This should be using the same caching logic as the query system + * instead of searching with `includes`. + */ + if (target !== undefined && world.query(targetQuery).includes(target)) { + callback(entity, target); + } + }; + } + + const pairTarget = input.target; + if (pairTarget === '*') return callback; + + return (entity: Entity, target?: Entity) => { + if (target === pairTarget) callback(entity, target); + }; + } + + return callback; + } + + const world = { + [$internal]: { + entityIndex: createEntityIndex(id), + entityMasks: [[]], + entityTraits: new Map(), + bitflag: 1, + traitInstances: [], + relations: new Set(), + queriesHashMap: new Map(), + queryInstances: [], + actionInstances: [], + notQueries: new Set(), + dirtyQueries: new Set(), + dirtyMasks: new Map(), + trackingSnapshots: new Map(), + changedMasks: new Map(), + worldEntity: null!, + trackedTraits: new Set(), + resetSubscriptions: new Set(), + sortCaches: new Map(), + } as WorldInternal, + + traits: new Set(), + + init(...initTraits: ConfigurableTrait[]) { + const ctx = world[$internal]; + if (isInitialized) return; + + isInitialized = true; + universe.worlds[id] = world; + + // Create uninitialized added masks. + const cursor = getTrackingCursor(); + for (let i = 0; i < cursor; i++) { + setTrackingMasks(world, i); + } + + // Register system traits. + if (!hasTraitInstance(ctx.traitInstances, IsExcluded)) registerTrait(world, IsExcluded); + + // Check for traits passed into lazy init + if (lazyTraits) { + initTraits = lazyTraits; + // clear lazyTraits + lazyTraits = undefined; + } + // Create world entity. + ctx.worldEntity = createEntity(world, IsExcluded, ...initTraits); + }, + + spawn(...spawnTraits: ConfigurableTrait[]): Entity { + return createEntity(world, ...spawnTraits); + }, + + has(target: Entity | Trait): boolean { + return typeof target === 'number' + ? isEntityAlive(world[$internal].entityIndex, target) + : hasTrait(world, world[$internal].worldEntity, target); + }, + + add(...addTraits: ConfigurableTrait[]) { + addTrait(world, world[$internal].worldEntity, ...addTraits); + }, + + remove(...removeTraits: Trait[]) { + removeTrait(world, world[$internal].worldEntity, ...removeTraits); + }, + + get(trait: T): TraitRecord> | undefined { + return getTrait(world, world[$internal].worldEntity, trait); + }, + + set(trait: T, value: TraitValue> | SetTraitCallback) { + setTrait(world, world[$internal].worldEntity, trait, value, true); + }, + + destroy() { + // Destroy world entity. + destroyEntity(world, world[$internal].worldEntity); + world[$internal].worldEntity = null!; + + world.reset(); + isInitialized = false; + // Clean up universe side effects. + releaseWorldId(universe.worldIndex, id); + universe.worlds[id] = null; + }, + + reset() { + lazyTraits = undefined; + const ctx = world[$internal]; + + // Destroy all entities so any cleanup is done. + world.entities.forEach((entity) => { + // Some relations may have caused the entity to be destroyed before + // we get to them in the loop. + if (world.has(entity)) { + destroyEntity(world, entity); + } + }); + + ctx.entityIndex = createEntityIndex(id); + ctx.entityTraits.clear(); + ctx.entityMasks = [[]]; + ctx.bitflag = 1; + + for (const query of ctx.queriesHashMap.values()) { + for (let i = 0; i < query.cleanup.length; i++) { + query.cleanup[i](); + } + } + + clearTraitInstance(ctx.traitInstances); + world.traits.clear(); + ctx.relations.clear(); + + ctx.queriesHashMap.clear(); + ctx.queryInstances.length = 0; + ctx.actionInstances.length = 0; + ctx.dirtyQueries.clear(); + ctx.notQueries.clear(); + + ctx.trackingSnapshots.clear(); + ctx.dirtyMasks.clear(); + ctx.changedMasks.clear(); + ctx.trackedTraits.clear(); + ctx.sortCaches.clear(); + + // Create new world entity. + ctx.worldEntity = createEntity(world, IsExcluded); + + for (const sub of ctx.resetSubscriptions) { + sub(world); + } + }, + + query(...args: any[]) { + const ctx = world[$internal]; + + // Check if first arg is a QueryRef + if (args.length === 1 && isQuery(args[0])) { + const queryRef = args[0]; + // Try array lookup first + let query = ctx.queryInstances[queryRef.id]; + if (query) return query.run(world, queryRef.parameters); + + // Fallback to hash map + query = ctx.queriesHashMap.get(queryRef.hash); + if (!query) { + query = createQueryInstance(world, queryRef.parameters); + ctx.queriesHashMap.set(queryRef.hash, query); + // Store in array for fast future lookups + if (queryRef.id >= ctx.queryInstances.length) { + ctx.queryInstances.length = queryRef.id + 1; + } + ctx.queryInstances[queryRef.id] = query; + } + return query.run(world, queryRef.parameters); + } else { + const params = args as QueryParameter[]; + + // Fast path: single relation pair with specific target + if (params.length === 1 && isRelationPair(params[0])) { + const relation = params[0].relation; + const target = params[0].target; + + // Only use fast path for specific targets + if (!params[0].targetQuery && typeof target === 'number') { + const entities = getEntitiesWithRelationTo( + world, + relation as Relation, + target as Entity + ); + return createRelationOnlyQueryResult(entities as Entity[], { + world, + relationTrait: (relation as Relation)[$internal].trait, + target: target as Entity, + }); + } + } + + const hash = createQueryHash(params); + let query = ctx.queriesHashMap.get(hash); + + if (!query) { + query = createQueryInstance(world, params); + ctx.queriesHashMap.set(hash, query); + } + + return query.run(world, params); + } + }, + + queryFirst(...args: [string] | QueryParameter[]) { + // @ts-expect-error - Having an issue with the TS overloads. + return world.query(...args)[0]; + }, + + onQueryAdd( + args: Query | QueryParameter[], + callback: (entity: Entity) => void + ): QueryUnsubscriber { + const ctx = world[$internal]; + let query: QueryInstance; + + // Check if args is a QueryRef object + if (isQuery(args)) { + const queryRef = args; + query = ctx.queryInstances[queryRef.id] || ctx.queriesHashMap.get(queryRef.hash)!; + + if (!query) { + query = createQueryInstance(world, queryRef.parameters); + ctx.queriesHashMap.set(queryRef.hash, query); + if (queryRef.id >= ctx.queryInstances.length) { + ctx.queryInstances.length = queryRef.id + 1; + } + ctx.queryInstances[queryRef.id] = query; + } + } else { + const hash = createQueryHash(args as QueryParameter[]); + query = ctx.queriesHashMap.get(hash)!; + + if (!query) { + query = createQueryInstance(world, args as QueryParameter[]); + ctx.queriesHashMap.set(hash, query); + } + } + + query.addSubscriptions.add(callback); + + return () => query.addSubscriptions.delete(callback); + }, + + onQueryRemove( + args: Query | QueryParameter[], + callback: (entity: Entity) => void + ): QueryUnsubscriber { + const ctx = world[$internal]; + let query: QueryInstance; + + // Check if args is a QueryRef object + if (isQuery(args)) { + const queryRef = args; + query = ctx.queryInstances[queryRef.id] || ctx.queriesHashMap.get(queryRef.hash)!; + + if (!query) { + query = createQueryInstance(world, queryRef.parameters); + ctx.queriesHashMap.set(queryRef.hash, query); + if (queryRef.id >= ctx.queryInstances.length) { + ctx.queryInstances.length = queryRef.id + 1; + } + ctx.queryInstances[queryRef.id] = query; + } + } else { + const hash = createQueryHash(args as QueryParameter[]); + query = ctx.queriesHashMap.get(hash)!; + + if (!query) { + query = createQueryInstance(world, args as QueryParameter[]); + ctx.queriesHashMap.set(hash, query); + } + } + + query.removeSubscriptions.add(callback); + + return () => query.removeSubscriptions.delete(callback); + }, + + onAdd( + trait: T | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ): QueryUnsubscriber { + const ctx = world[$internal]; + const resolvedTrait = resolveHookTrait(trait); + const resolvedCallback = resolveHookCallback(trait, callback); + + let data = getTraitInstance(ctx.traitInstances, resolvedTrait); + + if (!data) { + registerTrait(world, resolvedTrait); + data = getTraitInstance(ctx.traitInstances, resolvedTrait)!; + } + + data.addSubscriptions.add(resolvedCallback); + + return () => data.addSubscriptions.delete(resolvedCallback); + }, + + onRemove( + trait: T | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ): QueryUnsubscriber { + const ctx = world[$internal]; + const resolvedTrait = resolveHookTrait(trait); + const resolvedCallback = resolveHookCallback(trait, callback); + + let data = getTraitInstance(ctx.traitInstances, resolvedTrait); + + if (!data) { + registerTrait(world, resolvedTrait); + data = getTraitInstance(ctx.traitInstances, resolvedTrait)!; + } + + data.removeSubscriptions.add(resolvedCallback); + + return () => data.removeSubscriptions.delete(resolvedCallback); + }, + + onChange( + trait: Trait | Relation | RelationPair, + callback: (entity: Entity, target?: Entity) => void + ) { + const ctx = world[$internal]; + const resolvedTrait = resolveHookTrait(trait); + const resolvedCallback = resolveHookCallback(trait, callback); + + if (!hasTraitInstance(ctx.traitInstances, resolvedTrait)) registerTrait(world, resolvedTrait); + + const data = getTraitInstance(ctx.traitInstances, resolvedTrait)!; + data.changeSubscriptions.add(resolvedCallback); + + ctx.trackedTraits.add(resolvedTrait); + + return () => { + data.changeSubscriptions.delete(resolvedCallback); + if (data.changeSubscriptions.size === 0) ctx.trackedTraits.delete(resolvedTrait); + }; + }, + } as World; + + // Read-only properties via getters + Object.defineProperty(world, 'id', { + get: () => id, + enumerable: true, + }); + Object.defineProperty(world, 'isInitialized', { + get: () => isInitialized, + enumerable: true, + }); + Object.defineProperty(world, 'entities', { + get: () => getAliveEntities(world[$internal].entityIndex), + enumerable: true, + }); + + // Handle initialization based on arguments + if ( + optionsOrFirstTrait && + typeof optionsOrFirstTrait === 'object' && + !Array.isArray(optionsOrFirstTrait) + ) { + const { traits: optionTraits = [], lazy = false } = optionsOrFirstTrait as WorldOptions; + if (!lazy) { + world.init(...optionTraits); + } else { + lazyTraits = optionTraits; + } + } else { + world.init(...(optionsOrFirstTrait ? [optionsOrFirstTrait, ...traits] : traits)); + } + + return world; +} diff --git a/packages/koota-solid/tsconfig.json b/packages/koota/tsconfig.json similarity index 100% rename from packages/koota-solid/tsconfig.json rename to packages/koota/tsconfig.json diff --git a/packages/reconciler/package.json b/packages/reconciler/package.json index fea74292..e264053f 100644 --- a/packages/reconciler/package.json +++ b/packages/reconciler/package.json @@ -21,7 +21,7 @@ "@diffusionstudio/assets": "*", "@diffusionstudio/jsx": "*", "@diffusionstudio/runtime": "*", - "koota": "^0.6.6" + "@diffusionstudio/koota": "*" }, "peerDependencies": { "solid-js": "^1.9.0" diff --git a/packages/reconciler/src/document.ts b/packages/reconciler/src/document.ts index 8bdd4b3b..b756af6f 100644 --- a/packages/reconciler/src/document.ts +++ b/packages/reconciler/src/document.ts @@ -7,12 +7,12 @@ import { Active, AdjustmentLayer, Animation, AnimationPhase, AnimationType, appe import { LOOP_ATTR, parseTime, SOURCE_ATTR } from '@diffusionstudio/jsx'; import { createSignal } from 'solid-js'; import { SVGElements } from 'solid-js/web'; -import { IsExcluded } from 'koota'; +import { IsExcluded } from '@diffusionstudio/koota'; import type { CameraMatrix, PropertyPath, SceneNode } from '@diffusionstudio/runtime'; import type { AnimatableProperty, AssetRef } from '@diffusionstudio/jsx'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { ProjectDocument, ProjectTick } from './host'; /** Props that address or wire an element rather than describe it. */ diff --git a/packages/reconciler/src/mount.ts b/packages/reconciler/src/mount.ts index 2d89d281..0c9f71b9 100644 --- a/packages/reconciler/src/mount.ts +++ b/packages/reconciler/src/mount.ts @@ -8,7 +8,7 @@ import { createRuntimeDocument } from './document'; import { evaluate } from './evaluate'; import { renderProject } from './renderer'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; export interface Mount { /** Tears down the reactive graph and every entity the project rendered. */ diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 29d61374..a0dde6e2 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -2,4 +2,4 @@ Headless editor runtime built on [koota](https://github.com/pmndrs/koota): traits, world creation and serialization, actions, deterministic systems, media decoding, and capture. -Must work without a DOM and without solid-js so the CLI and node capture can consume it directly. Browser-only concerns (input, keyboard, HUD, timeline canvas UI, persistence) live in `apps/web`; Solid reactivity bindings live in `@diffusionstudio/koota-solid`. +Must work without a DOM and without solid-js so the CLI and node capture can consume it directly. Browser-only concerns (input, keyboard, HUD, timeline canvas UI, persistence) live in `apps/web`; Solid reactivity bindings live in `@diffusionstudio/koota/solid`. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6dfa3451..c6407f7d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -24,7 +24,7 @@ "@diffusionstudio/jsx": "*", "animejs": "^4.4.1", "colord": "^2.9.3", - "koota": "^0.6.6", + "@diffusionstudio/koota": "*", "mediabunny": "^1.32.2" }, "devDependencies": { diff --git a/packages/runtime/src/actions/assets.ts b/packages/runtime/src/actions/assets.ts index 14c73ef5..b31639a9 100644 --- a/packages/runtime/src/actions/assets.ts +++ b/packages/runtime/src/actions/assets.ts @@ -9,7 +9,7 @@ import { Ai, AssetId, Library, Paint, SourceFrameRate, SourceModifiers } from '../traits'; import { PaintType } from '../constants'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset, AssetLibrary, SequenceAsset } from '@diffusionstudio/assets'; import type { GenAi } from '../ai'; diff --git a/packages/runtime/src/actions/cache.ts b/packages/runtime/src/actions/cache.ts index eb509237..3ea51ef0 100644 --- a/packages/runtime/src/actions/cache.ts +++ b/packages/runtime/src/actions/cache.ts @@ -6,7 +6,7 @@ // KeyframeTrack.target are derived state, so they are written through stores // without change events; the app observes the authoring writes instead. -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { @@ -19,7 +19,7 @@ import { getParentNode } from '../queries/hierarchy'; import { sortByFrame, sortByItemIndex } from '../utils/sort'; import { resetAnimatedValues } from '../systems/motion'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Refresh the parent's Cache lists that the attached/detached entity diff --git a/packages/runtime/src/actions/camera.ts b/packages/runtime/src/actions/camera.ts index 03f46358..d90060e3 100644 --- a/packages/runtime/src/actions/camera.ts +++ b/packages/runtime/src/actions/camera.ts @@ -14,7 +14,7 @@ import { MAX_CAMERA_ZOOM, MIN_CAMERA_ZOOM } from '../constants'; import { clamp, transformPoint } from '../math'; import { getCamera, getCameraInverse, getCameraScale, getContentBounds, getEntityBounds, getViewport } from '../queries/camera'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Camera2D } from '../traits'; import type { CameraMatrix } from '../queries/camera'; import type { Rect } from '../math'; diff --git a/packages/runtime/src/actions/clipboard.ts b/packages/runtime/src/actions/clipboard.ts index f38c8a0a..19948cb9 100644 --- a/packages/runtime/src/actions/clipboard.ts +++ b/packages/runtime/src/actions/clipboard.ts @@ -6,7 +6,7 @@ // api/clipboard.ts). Module-level, in-memory; navigator.clipboard bridging // stays app-side. -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { Geometry, Group, Scene, Selected, Position, Computed } from '../traits'; import { store } from '../world/store'; @@ -16,7 +16,7 @@ import { } from '../world/serialize'; import { removeChild, appendChild } from './hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { EntityRecord } from '../world/serialize'; type ClipboardEntry = { diff --git a/packages/runtime/src/actions/entities.ts b/packages/runtime/src/actions/entities.ts index d2de999f..6ca40680 100644 --- a/packages/runtime/src/actions/entities.ts +++ b/packages/runtime/src/actions/entities.ts @@ -8,7 +8,7 @@ import { DEFAULT_DURATION_FRAMES } from '../constants'; import { Computed, Cache } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Spawn an empty document entity. Computed/Cache are pre-attached (systems diff --git a/packages/runtime/src/actions/frame.ts b/packages/runtime/src/actions/frame.ts index a1b67e83..251a1d5d 100644 --- a/packages/runtime/src/actions/frame.ts +++ b/packages/runtime/src/actions/frame.ts @@ -6,7 +6,7 @@ // api/base.ts). The app reacts to Active changes (clearing timeline buffers, // rebuilding its index) through its own trait observer. -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { GeometryType, PaintType } from '../constants'; @@ -23,7 +23,7 @@ import { createEntity } from './entities'; import { appendChild, removeChild } from './hierarchy'; import { resizeEntity } from './resize'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** The entity carrying Active, or null. */ export function getActiveEntity(world: World): Entity | null { diff --git a/packages/runtime/src/actions/group.ts b/packages/runtime/src/actions/group.ts index 3606ea6c..eaa2678c 100644 --- a/packages/runtime/src/actions/group.ts +++ b/packages/runtime/src/actions/group.ts @@ -4,7 +4,7 @@ // Group and ungroup actions (was api/group.ts). -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { @@ -19,7 +19,7 @@ import { createEntity, deleteEntity } from './entities'; import { appendChild, removeChild } from './hierarchy'; import { resolveNewSequenceOverlaps } from './overlap'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Wrap the current node selection in a new GROUP. All selected nodes must diff --git a/packages/runtime/src/actions/hierarchy.ts b/packages/runtime/src/actions/hierarchy.ts index d16b4730..7bb7d07d 100644 --- a/packages/runtime/src/actions/hierarchy.ts +++ b/packages/runtime/src/actions/hierarchy.ts @@ -8,14 +8,14 @@ // Cache/size/constraint/time-range fixups ride on the ChildOf events (see // world/observers.ts), so these functions only validate and re-target. -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { ChildOf, Geometry, Group, AdjustmentLayer, ItemIndex, Selected, Root } from '../traits'; import { getEntityTree, getParentNode } from '../queries/hierarchy'; import { assert } from '../utils/assert'; import { sortByItemIndex } from '../utils/sort'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export type ReorderTarget = 'front' | 'back' | number; diff --git a/packages/runtime/src/actions/interactive.ts b/packages/runtime/src/actions/interactive.ts index d867f995..47b6b730 100644 --- a/packages/runtime/src/actions/interactive.ts +++ b/packages/runtime/src/actions/interactive.ts @@ -7,7 +7,7 @@ // decides what clicking the canvas can reach, and drilling into a container // moves the tag down a level. -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { AdjustmentLayer, ChildOf, Culled, Geometry, Group, Hovering, Interactive, @@ -16,7 +16,7 @@ import { import { isScene } from '../queries/predicates'; import { isPointerInEntity } from '../queries/interaction'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Point } from '../math'; /** diff --git a/packages/runtime/src/actions/keyframe.ts b/packages/runtime/src/actions/keyframe.ts index 503bfee6..0ce0ca3a 100644 --- a/packages/runtime/src/actions/keyframe.ts +++ b/packages/runtime/src/actions/keyframe.ts @@ -12,7 +12,7 @@ import { getPropertyPaths } from '../systems/motion'; import { createEntity, deleteEntity } from './entities'; import { appendChild } from './hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { PropertyPath } from '../systems/motion'; /** diff --git a/packages/runtime/src/actions/overlap.ts b/packages/runtime/src/actions/overlap.ts index 47ad30eb..3ebded4d 100644 --- a/packages/runtime/src/actions/overlap.ts +++ b/packages/runtime/src/actions/overlap.ts @@ -5,7 +5,7 @@ // Sequential overlap resolution (was api/overlap.ts). Lives in actions, not // queries: it trims, deletes, and splits clips via the overwrite model. -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { @@ -17,7 +17,7 @@ import { cloneSubtree } from '../world/serialize'; import { deleteEntity } from './entities'; import { trimEntityIn, trimEntityOut } from './timing'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Enforce the "no two direct children overlap in time" invariant of a diff --git a/packages/runtime/src/actions/playback.ts b/packages/runtime/src/actions/playback.ts index d1be771f..ff8ed587 100644 --- a/packages/runtime/src/actions/playback.ts +++ b/packages/runtime/src/actions/playback.ts @@ -17,7 +17,7 @@ import { PaintType } from '../constants'; import { getIntrinsicPaint } from '../utils/time'; import { getParentEntity } from '../queries/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Whether a node plays media of its own: audio, or video through its diff --git a/packages/runtime/src/actions/resize.ts b/packages/runtime/src/actions/resize.ts index 037b5317..0241d85d 100644 --- a/packages/runtime/src/actions/resize.ts +++ b/packages/runtime/src/actions/resize.ts @@ -13,7 +13,7 @@ import { } from '../traits'; import { getParentNode } from '../queries/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; type ResizeParams = { width?: number; height?: number }; diff --git a/packages/runtime/src/actions/timing.ts b/packages/runtime/src/actions/timing.ts index 5138e5b4..e3d3dcb4 100644 --- a/packages/runtime/src/actions/timing.ts +++ b/packages/runtime/src/actions/timing.ts @@ -19,7 +19,7 @@ import { findAssetDuration, getSourceFrameAt, isPaintEntity, secondsToFrames } f import { getAsset } from './assets'; import { getTranscriptDuration, primeTranscriptDuration } from '../media/caption/utils'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Ignorable, TimeTrait } from '../utils/time'; /** diff --git a/packages/runtime/src/ai.ts b/packages/runtime/src/ai.ts index 25e6516c..b1c67b92 100644 --- a/packages/runtime/src/ai.ts +++ b/packages/runtime/src/ai.ts @@ -4,7 +4,7 @@ import { generate } from '@diffusionstudio/jsx'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { SourceModifierValues } from './actions/assets'; import type { diff --git a/packages/runtime/src/fonts/utils.ts b/packages/runtime/src/fonts/utils.ts index 64d3b4a7..40baeadc 100644 --- a/packages/runtime/src/fonts/utils.ts +++ b/packages/runtime/src/fonts/utils.ts @@ -10,7 +10,7 @@ import { WebFonts } from './fixtures'; import { FontStyle } from '../constants'; import { Fonts } from '../traits'; -import type { World } from 'koota'; +import type { World } from '@diffusionstudio/koota'; import type * as types from './types'; const STYLE_MAP = { diff --git a/packages/runtime/src/media/audio-bus.ts b/packages/runtime/src/media/audio-bus.ts index 85d2d1bb..c7b1218e 100644 --- a/packages/runtime/src/media/audio-bus.ts +++ b/packages/runtime/src/media/audio-bus.ts @@ -7,7 +7,7 @@ import { Muted, Computed, AudioEngine } from '../traits'; import { attempt } from '../utils/async'; import { assert } from '../utils/assert'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Per-entity audio bus. Each clip and each scene gets its own gain node. diff --git a/packages/runtime/src/media/audio-peaks.ts b/packages/runtime/src/media/audio-peaks.ts index aad92952..6c595a23 100644 --- a/packages/runtime/src/media/audio-peaks.ts +++ b/packages/runtime/src/media/audio-peaks.ts @@ -7,7 +7,7 @@ import { derivePeaks } from '@diffusionstudio/assets'; import { Library, WaveformHandle, AssetId } from '../traits'; import { getAsset, getAssetFile } from '../actions/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { AssetCache, AudioAsset, VideoAsset } from '@diffusionstudio/assets'; /** diff --git a/packages/runtime/src/media/audio.ts b/packages/runtime/src/media/audio.ts index df05a8dd..02bfa660 100644 --- a/packages/runtime/src/media/audio.ts +++ b/packages/runtime/src/media/audio.ts @@ -10,7 +10,7 @@ import { assert } from '../utils/assert'; import { getAsset, getAssetFile } from '../actions/assets'; import { TimeStretcher } from './time-stretcher'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { AudioAsset, VideoAsset } from '@diffusionstudio/assets'; import type { AudioBus } from './audio-bus'; diff --git a/packages/runtime/src/media/caption/cascade.ts b/packages/runtime/src/media/caption/cascade.ts index 6515b845..f86f7f25 100644 --- a/packages/runtime/src/media/caption/cascade.ts +++ b/packages/runtime/src/media/caption/cascade.ts @@ -8,7 +8,7 @@ import { loadWebFont } from '../../fonts/utils'; import { groupBy, findActiveGroup, resolveTranscript, setChars } from './utils'; import { placeCaption } from './position'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/classic.ts b/packages/runtime/src/media/caption/classic.ts index c6d596cc..ce2cc155 100644 --- a/packages/runtime/src/media/caption/classic.ts +++ b/packages/runtime/src/media/caption/classic.ts @@ -11,7 +11,7 @@ import { placeCaption } from './position'; import { createEntity } from '../../actions/entities'; import { appendChild } from '../../actions/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/guinea.ts b/packages/runtime/src/media/caption/guinea.ts index 146bda41..e3db8563 100644 --- a/packages/runtime/src/media/caption/guinea.ts +++ b/packages/runtime/src/media/caption/guinea.ts @@ -12,7 +12,7 @@ import { placeCaption } from './position'; import { createEntity } from '../../actions/entities'; import { appendChild } from '../../actions/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/index.ts b/packages/runtime/src/media/caption/index.ts index 81fbf2ab..497bbeef 100644 --- a/packages/runtime/src/media/caption/index.ts +++ b/packages/runtime/src/media/caption/index.ts @@ -14,7 +14,7 @@ import { PaperCaptionDecoder, PAPER_TEXT_STYLE } from './paper'; import { GuineaCaptionDecoder, GUINEA_TEXT_STYLE } from './guinea'; import { StarkCaptionDecoder, STARK_TEXT_STYLE } from './stark'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/paper.ts b/packages/runtime/src/media/caption/paper.ts index 8f849c72..88ec4cb4 100644 --- a/packages/runtime/src/media/caption/paper.ts +++ b/packages/runtime/src/media/caption/paper.ts @@ -11,7 +11,7 @@ import { placeCaption } from './position'; import { createEntity } from '../../actions/entities'; import { appendChild } from '../../actions/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/position.ts b/packages/runtime/src/media/caption/position.ts index 5219e224..a2418fea 100644 --- a/packages/runtime/src/media/caption/position.ts +++ b/packages/runtime/src/media/caption/position.ts @@ -8,7 +8,7 @@ import { Caption, Position, Computed, Host } from '../../traits'; import { getParentNode } from '../../queries/hierarchy'; import { resizeEntity } from '../../actions/resize'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; // Margin between the caption box and the parent edge for top/bottom alignment. export const CAPTION_MARGIN = 100; diff --git a/packages/runtime/src/media/caption/spotlight.ts b/packages/runtime/src/media/caption/spotlight.ts index 2c092745..19541cea 100644 --- a/packages/runtime/src/media/caption/spotlight.ts +++ b/packages/runtime/src/media/caption/spotlight.ts @@ -12,7 +12,7 @@ import { placeCaption } from './position'; import { createEntity } from '../../actions/entities'; import { appendChild } from '../../actions/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/stark.ts b/packages/runtime/src/media/caption/stark.ts index 5e2fb207..ea19383e 100644 --- a/packages/runtime/src/media/caption/stark.ts +++ b/packages/runtime/src/media/caption/stark.ts @@ -11,7 +11,7 @@ import { placeCaption } from './position'; import { createEntity } from '../../actions/entities'; import { appendChild } from '../../actions/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/caption/types.ts b/packages/runtime/src/media/caption/types.ts index a662e16d..c6adb231 100644 --- a/packages/runtime/src/media/caption/types.ts +++ b/packages/runtime/src/media/caption/types.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { CaptionType, FontStyle, TextAlign, TextBaseline, TextCase } from '../../constants'; import type { WordGroup } from '@diffusionstudio/assets'; diff --git a/packages/runtime/src/media/caption/utils.ts b/packages/runtime/src/media/caption/utils.ts index 23a7f09b..66a33ae8 100644 --- a/packages/runtime/src/media/caption/utils.ts +++ b/packages/runtime/src/media/caption/utils.ts @@ -8,7 +8,7 @@ import { deleteEntity } from '../../actions/entities'; import { getAssetFile } from '../../actions/assets'; import { parseSubtitles } from './subtitles'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset, Transcript, WordGroup } from '@diffusionstudio/assets'; export type TextRangeOverride = { diff --git a/packages/runtime/src/media/caption/whisper.ts b/packages/runtime/src/media/caption/whisper.ts index eedddf3e..b4a24081 100644 --- a/packages/runtime/src/media/caption/whisper.ts +++ b/packages/runtime/src/media/caption/whisper.ts @@ -8,7 +8,7 @@ import { loadWebFont } from '../../fonts/utils'; import { groupBy, findActiveGroup, resolveTranscript, setChars } from './utils'; import { placeCaption } from './position'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; import type { CaptionDecoder, CaptionPresetStyle } from './types'; diff --git a/packages/runtime/src/media/dispose.ts b/packages/runtime/src/media/dispose.ts index 28025e7c..d0af935c 100644 --- a/packages/runtime/src/media/dispose.ts +++ b/packages/runtime/src/media/dispose.ts @@ -13,7 +13,7 @@ import { AudioDecoderHandle, CaptionDecoderHandle, WaveformHandle, AudioBusHandle, } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export function disposeDecoders(world: World, entity: Entity): void { if (entity.has(ImageDecoderHandle)) { diff --git a/packages/runtime/src/media/handoff.ts b/packages/runtime/src/media/handoff.ts index 3471dd0d..2251b46c 100644 --- a/packages/runtime/src/media/handoff.ts +++ b/packages/runtime/src/media/handoff.ts @@ -13,7 +13,7 @@ import { AssetId, VideoDecoderHandle } from '../traits'; import { getEntityChildren } from '../queries/hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Moves the live video decoders of `from`'s subtree to `to`'s. diff --git a/packages/runtime/src/media/image.ts b/packages/runtime/src/media/image.ts index 84c0a7e3..55b99875 100644 --- a/packages/runtime/src/media/image.ts +++ b/packages/runtime/src/media/image.ts @@ -5,7 +5,7 @@ import { AssetId, ImageDecoderHandle } from '../traits'; import { getAsset, getAssetFile } from '../actions/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { ImageAsset } from '@diffusionstudio/assets'; export type DecodedImage = ImageBitmap | HTMLImageElement; diff --git a/packages/runtime/src/media/shader.ts b/packages/runtime/src/media/shader.ts index 3fe637ab..089b3cab 100644 --- a/packages/runtime/src/media/shader.ts +++ b/packages/runtime/src/media/shader.ts @@ -5,7 +5,7 @@ import { Shader, ShaderHostHandle } from "../traits"; import { parseColor } from "../utils/color"; -import type { Entity, World } from "koota"; +import type { Entity, World } from "@diffusionstudio/koota"; type Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; type UniformValue = number | number[] | string; diff --git a/packages/runtime/src/media/video.ts b/packages/runtime/src/media/video.ts index 7540149c..484dc569 100644 --- a/packages/runtime/src/media/video.ts +++ b/packages/runtime/src/media/video.ts @@ -11,7 +11,7 @@ import { FrameCache } from './frame-cache'; import { getKeyframeIndex } from './keyframe-index'; import { SequenceDecoder } from './sequence'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { KeyframeIndex } from './keyframe-index'; import type { VideoAsset } from '@diffusionstudio/assets'; diff --git a/packages/runtime/src/queries/camera.ts b/packages/runtime/src/queries/camera.ts index efb8e053..ee0b0424 100644 --- a/packages/runtime/src/queries/camera.ts +++ b/packages/runtime/src/queries/camera.ts @@ -7,13 +7,13 @@ // transform systems scale it by RenderSurface.resolution to reach device // pixels (getViewMatrix). Writes live in actions/camera.ts. -import { Or } from 'koota'; +import { Or } from '@diffusionstudio/koota'; import { Camera, Root, RenderSurface, WorldBounds, Geometry, Group, Hidden, ChildOf } from '../traits'; import { store } from '../world/store'; import { invert2D, transformPoint } from '../math'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Camera2D } from '../traits'; import type { Mat2D, Point, Rect } from '../math'; diff --git a/packages/runtime/src/queries/hierarchy.ts b/packages/runtime/src/queries/hierarchy.ts index 5bfb1f28..b2c8b959 100644 --- a/packages/runtime/src/queries/hierarchy.ts +++ b/packages/runtime/src/queries/hierarchy.ts @@ -7,7 +7,7 @@ import { isStage, isScene } from './predicates'; // Direct module, not the utils barrel: utils/time reaches back into this file. import { sortByItemIndex } from '../utils/sort'; -import type { Entity, World, QueryParameter } from 'koota'; +import type { Entity, World, QueryParameter } from '@diffusionstudio/koota'; export function getParentEntity(entity: Entity | null | undefined): Entity | null { if (!entity) return null; diff --git a/packages/runtime/src/queries/hit-test.ts b/packages/runtime/src/queries/hit-test.ts index e7bd227c..55e4dee7 100644 --- a/packages/runtime/src/queries/hit-test.ts +++ b/packages/runtime/src/queries/hit-test.ts @@ -7,14 +7,14 @@ // straight into the SoA store by the transform system (never added as a // trait), so it is read by entity id here, like WorldBounds in ./camera. -import { Not } from 'koota'; +import { Not } from '@diffusionstudio/koota'; import { Computed, Culled, Geometry, Hidden, LocalTransform, Scene } from '../traits'; import { store } from '../world/store'; import { aabbFromTransformedRect, invert2D, multiply2D, transformPoint } from '../math'; import { getParentNode } from './hierarchy'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Mat2D, Point } from '../math'; /** Below this the transform is singular and the mapping is undefined. */ diff --git a/packages/runtime/src/queries/interaction.ts b/packages/runtime/src/queries/interaction.ts index 4f3fd63c..ab8f6b85 100644 --- a/packages/runtime/src/queries/interaction.ts +++ b/packages/runtime/src/queries/interaction.ts @@ -10,7 +10,7 @@ // entity id: the transform system writes them there without ever adding the // traits. -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { Anchor, Computed, Geometry, Group, LocalTransform, Offset, Selected, @@ -24,7 +24,7 @@ import { scale2D, skew2D, transformPoint, translate2D, } from '../math'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Mat2D, Point, Quad } from '../math'; /** diff --git a/packages/runtime/src/queries/predicates.ts b/packages/runtime/src/queries/predicates.ts index a7703300..58af987a 100644 --- a/packages/runtime/src/queries/predicates.ts +++ b/packages/runtime/src/queries/predicates.ts @@ -8,7 +8,7 @@ import { } from '../traits'; import { GeometryType, PaintType } from '../constants'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; export function isStage(entity: Entity): boolean { return entity.has(Stage); diff --git a/packages/runtime/src/queries/timeline-index.ts b/packages/runtime/src/queries/timeline-index.ts index 0c77d98e..2695ae3a 100644 --- a/packages/runtime/src/queries/timeline-index.ts +++ b/packages/runtime/src/queries/timeline-index.ts @@ -9,7 +9,7 @@ import { import { isSequence } from './predicates'; import { sortByItemIndex } from '../utils/sort'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export type TimelineNodeKind = 'geometry' | 'sub-item' | 'keyframe-track'; diff --git a/packages/runtime/src/systems/assets.ts b/packages/runtime/src/systems/assets.ts index 05f0bf26..0d26a259 100644 --- a/packages/runtime/src/systems/assets.ts +++ b/packages/runtime/src/systems/assets.ts @@ -12,7 +12,7 @@ import { computeAudioSyncOffsetCached } from '../media/audio-sync'; import { store } from '../world/store'; import { assert } from '../utils/assert'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; export function assetSystem(world: World): void { diff --git a/packages/runtime/src/systems/gradients.ts b/packages/runtime/src/systems/gradients.ts index d42f56ef..b5791293 100644 --- a/packages/runtime/src/systems/gradients.ts +++ b/packages/runtime/src/systems/gradients.ts @@ -10,7 +10,7 @@ import { store } from '../world/store'; import { ChildOf, ColorStop, Position, Computed } from '../traits'; import { colorToCss } from '../utils/color'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; type Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; diff --git a/packages/runtime/src/systems/motion.ts b/packages/runtime/src/systems/motion.ts index 842f1dd4..1c2e3620 100644 --- a/packages/runtime/src/systems/motion.ts +++ b/packages/runtime/src/systems/motion.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { cubicBezier, steps, spring } from 'animejs'; import { store } from '../world/store'; @@ -17,7 +17,7 @@ import { AnimationType, AnimationPhase } from '../constants'; import { revealChars, revealWords, scrambleChars } from '../utils/text-motion'; import { getLocalWindow } from '../utils/time'; -import type { Entity, Trait, TraitRecord, World } from 'koota'; +import type { Entity, Trait, TraitRecord, World } from '@diffusionstudio/koota'; /** * Reset an entity's Computed values back to its authored trait values. diff --git a/packages/runtime/src/systems/playback.ts b/packages/runtime/src/systems/playback.ts index 09b0c3c6..f5aa5610 100644 --- a/packages/runtime/src/systems/playback.ts +++ b/packages/runtime/src/systems/playback.ts @@ -7,7 +7,7 @@ // audio bus tree in sync, and steps live mounts. In offline modes the encoder // sets the playhead explicitly and awaits FramePromises. -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { PaintType } from '../constants'; @@ -31,7 +31,7 @@ import { import { whenHtmlReady } from '../media/html'; import { AudioBus } from '../media/audio-bus'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; const WARMUP_FRAMES = 15; diff --git a/packages/runtime/src/systems/render.ts b/packages/runtime/src/systems/render.ts index 6a39601d..f96d3e71 100644 --- a/packages/runtime/src/systems/render.ts +++ b/packages/runtime/src/systems/render.ts @@ -8,7 +8,7 @@ // regions are pushed callback-less (see HitRegions); the app's input layer // attaches its handlers. -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { @@ -39,7 +39,7 @@ import { resolveCaptionDecoder, resolveShaderHost, resolveWaveformPeaks, } from '../media'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Quad } from '../math/aabb'; type Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; diff --git a/packages/runtime/src/systems/transform.ts b/packages/runtime/src/systems/transform.ts index 410029f2..bfbb2f57 100644 --- a/packages/runtime/src/systems/transform.ts +++ b/packages/runtime/src/systems/transform.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Not, Or } from 'koota'; +import { Not, Or } from '@diffusionstudio/koota'; import { store } from '../world/store'; import { @@ -25,7 +25,7 @@ import { type Mat2D, } from '../math'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; // This system writes derived per-frame data (LocalTransform, WorldTransform, // WorldBounds, Computed group bounds) straight into trait stores: no diff --git a/packages/runtime/src/traits/audio.ts b/packages/runtime/src/traits/audio.ts index 4b3f2736..979df8a4 100644 --- a/packages/runtime/src/traits/audio.ts +++ b/packages/runtime/src/traits/audio.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; // Audio volume in decibels (0 dB = unity, -Infinity = silence). export const Volume = trait({ value: 0 }); diff --git a/packages/runtime/src/traits/derived.ts b/packages/runtime/src/traits/derived.ts index 7e02ba4e..c839cd82 100644 --- a/packages/runtime/src/traits/derived.ts +++ b/packages/runtime/src/traits/derived.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait, type Entity } from 'koota'; +import { trait, type Entity } from '@diffusionstudio/koota'; // Fully resolved per-frame values (base state + constraints + animations + // keyframes). Written by the motion and transform systems, read by render and diff --git a/packages/runtime/src/traits/document.ts b/packages/runtime/src/traits/document.ts index 9cd2dcb7..efd632c7 100644 --- a/packages/runtime/src/traits/document.ts +++ b/packages/runtime/src/traits/document.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import type { SceneNode as AuthoredSceneNode } from '@diffusionstudio/jsx'; diff --git a/packages/runtime/src/traits/interaction.ts b/packages/runtime/src/traits/interaction.ts index 0266e8df..f66890a7 100644 --- a/packages/runtime/src/traits/interaction.ts +++ b/packages/runtime/src/traits/interaction.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { ToolType } from '../constants'; diff --git a/packages/runtime/src/traits/media.ts b/packages/runtime/src/traits/media.ts index 4b9f85db..8939f889 100644 --- a/packages/runtime/src/traits/media.ts +++ b/packages/runtime/src/traits/media.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import type { ShaderHost } from '../media/shader'; import type { ImageDecoder } from '../media/image'; diff --git a/packages/runtime/src/traits/motion.ts b/packages/runtime/src/traits/motion.ts index 4916ce1a..40466633 100644 --- a/packages/runtime/src/traits/motion.ts +++ b/packages/runtime/src/traits/motion.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait, type Entity } from 'koota'; +import { trait, type Entity } from '@diffusionstudio/koota'; import { AnimationType, AnimationPhase } from '../constants'; diff --git a/packages/runtime/src/traits/node.ts b/packages/runtime/src/traits/node.ts index a7960d92..6dd4b2e8 100644 --- a/packages/runtime/src/traits/node.ts +++ b/packages/runtime/src/traits/node.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { GeometryType, PaintType, CaptionType, CaptionAlign } from '../constants'; diff --git a/packages/runtime/src/traits/relations.ts b/packages/runtime/src/traits/relations.ts index 86473c98..6f5a052f 100644 --- a/packages/runtime/src/traits/relations.ts +++ b/packages/runtime/src/traits/relations.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { relation } from 'koota'; +import { relation } from '@diffusionstudio/koota'; /** * Universal parent-child relation. Replaces all array-based ownership: diff --git a/packages/runtime/src/traits/style.ts b/packages/runtime/src/traits/style.ts index 126869af..320d5c93 100644 --- a/packages/runtime/src/traits/style.ts +++ b/packages/runtime/src/traits/style.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { BlendModeType, EffectType, ScaleModeType, StrokeJoin, StrokeCap } from '../constants'; diff --git a/packages/runtime/src/traits/text.ts b/packages/runtime/src/traits/text.ts index d8092397..e8d2ede9 100644 --- a/packages/runtime/src/traits/text.ts +++ b/packages/runtime/src/traits/text.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { FontStyle, TextAlign, TextBaseline, TextCase } from '../constants'; diff --git a/packages/runtime/src/traits/timing.ts b/packages/runtime/src/traits/timing.ts index 777df684..a07c55bb 100644 --- a/packages/runtime/src/traits/timing.ts +++ b/packages/runtime/src/traits/timing.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { TransitionType } from '../constants'; diff --git a/packages/runtime/src/traits/transform.ts b/packages/runtime/src/traits/transform.ts index d1e1c62a..11fd748c 100644 --- a/packages/runtime/src/traits/transform.ts +++ b/packages/runtime/src/traits/transform.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait } from 'koota'; +import { trait } from '@diffusionstudio/koota'; import { ConstraintType } from '../constants'; diff --git a/packages/runtime/src/traits/world.ts b/packages/runtime/src/traits/world.ts index 356f5269..7afbed10 100644 --- a/packages/runtime/src/traits/world.ts +++ b/packages/runtime/src/traits/world.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { trait, type Entity, type World } from 'koota'; +import { trait, type Entity, type World } from '@diffusionstudio/koota'; import type { FontSource } from '../fonts/types'; import type { AssetLibrary } from '@diffusionstudio/assets'; diff --git a/packages/runtime/src/utils/generating.ts b/packages/runtime/src/utils/generating.ts index cf67ecd7..665a8de6 100644 --- a/packages/runtime/src/utils/generating.ts +++ b/packages/runtime/src/utils/generating.ts @@ -21,7 +21,7 @@ import { cubicBezier } from 'animejs'; import { Cache, Generating, SourceError, Time } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; const generatingEase = cubicBezier(0.52, 0.18, 0.56, 0.88); diff --git a/packages/runtime/src/utils/sort.ts b/packages/runtime/src/utils/sort.ts index b455dbd7..005bb418 100644 --- a/packages/runtime/src/utils/sort.ts +++ b/packages/runtime/src/utils/sort.ts @@ -4,7 +4,7 @@ import { ItemIndex, Computed, Keyframe } from '../traits'; -import type { Entity } from 'koota'; +import type { Entity } from '@diffusionstudio/koota'; export function sortByItemIndex(a: Entity, b: Entity): number { diff --git a/packages/runtime/src/utils/stroke.ts b/packages/runtime/src/utils/stroke.ts index 977979ea..8876a429 100644 --- a/packages/runtime/src/utils/stroke.ts +++ b/packages/runtime/src/utils/stroke.ts @@ -6,7 +6,7 @@ import { store } from '../world/store'; import { StrokeCap, StrokeJoin } from '../constants'; import { StrokeStyle, Computed, Hidden } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; type Ctx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; diff --git a/packages/runtime/src/utils/text.ts b/packages/runtime/src/utils/text.ts index 9ba4a201..1665fa2a 100644 --- a/packages/runtime/src/utils/text.ts +++ b/packages/runtime/src/utils/text.ts @@ -19,7 +19,7 @@ import { colorToHex } from './color'; import { applyStrokeStyle, findWidestStroke } from './stroke'; import { createLinearGradient, createRadialGradient } from '../systems/gradients'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; type Ctx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; diff --git a/packages/runtime/src/utils/time.ts b/packages/runtime/src/utils/time.ts index 7c82e4c0..4ebb2d7c 100644 --- a/packages/runtime/src/utils/time.ts +++ b/packages/runtime/src/utils/time.ts @@ -10,7 +10,7 @@ import { import { getParentNode } from '../queries/hierarchy'; import { getSourceDuration } from '../actions/assets'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; import type { Asset } from '@diffusionstudio/assets'; export function snapToMs(seconds: number) { diff --git a/packages/runtime/src/utils/transition.ts b/packages/runtime/src/utils/transition.ts index 3de916a1..5bccdf53 100644 --- a/packages/runtime/src/utils/transition.ts +++ b/packages/runtime/src/utils/transition.ts @@ -5,7 +5,7 @@ import { store } from '../world/store'; import { Transition, Computed } from '../traits'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; export type TransitionWindow = { start: number; end: number; midpointFrame: number }; diff --git a/packages/runtime/src/world/create-world.ts b/packages/runtime/src/world/create-world.ts index 425ad6b4..e397bd37 100644 --- a/packages/runtime/src/world/create-world.ts +++ b/packages/runtime/src/world/create-world.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { createWorld } from 'koota'; +import { createWorld } from '@diffusionstudio/koota'; import { observeWorld } from './observers'; import { diff --git a/packages/runtime/src/world/observers.ts b/packages/runtime/src/world/observers.ts index 2b16805d..eb942f90 100644 --- a/packages/runtime/src/world/observers.ts +++ b/packages/runtime/src/world/observers.ts @@ -26,7 +26,7 @@ import { import { propagateSize, resolveConstraintOffsets } from '../actions/resize'; import { resetAnimatedValues } from '../systems/motion'; -import type { Entity, Trait, World } from 'koota'; +import type { Entity, Trait, World } from '@diffusionstudio/koota'; /** * Wire the runtime invariants into a world. Returns a disposer (worlds are diff --git a/packages/runtime/src/world/serialize.ts b/packages/runtime/src/world/serialize.ts index fa6f4f95..ab872b89 100644 --- a/packages/runtime/src/world/serialize.ts +++ b/packages/runtime/src/world/serialize.ts @@ -19,7 +19,7 @@ import { } from '../traits'; import { createEntity } from '../actions/entities'; -import type { Entity, World } from 'koota'; +import type { Entity, World } from '@diffusionstudio/koota'; /** * Plain-object snapshot of one entity's traits. Used to clone entity diff --git a/packages/runtime/src/world/store.ts b/packages/runtime/src/world/store.ts index 514558fa..8de81fff 100644 --- a/packages/runtime/src/world/store.ts +++ b/packages/runtime/src/world/store.ts @@ -2,9 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getStore } from 'koota'; +import { getStore } from '@diffusionstudio/koota'; -import type { ExtractStore, Trait, World } from 'koota'; +import type { ExtractStore, Trait, World } from '@diffusionstudio/koota'; /** * Direct SoA store access for hot paths (array indexing by entity.id(), no diff --git a/patches/koota+0.6.6.patch b/patches/koota+0.6.6.patch deleted file mode 100644 index 542029db..00000000 --- a/patches/koota+0.6.6.patch +++ /dev/null @@ -1,150 +0,0 @@ -diff --git a/node_modules/koota/dist/chunk-ZWIGMIL4.js b/node_modules/koota/dist/chunk-ZWIGMIL4.js -index feb90a8..16e3081 100644 ---- a/node_modules/koota/dist/chunk-ZWIGMIL4.js -+++ b/node_modules/koota/dist/chunk-ZWIGMIL4.js -@@ -176,6 +176,8 @@ function checkQuery(world, query, entity) { - const ctx = world[$internal]; - const eid = entity & ENTITY_ID_MASK; - if (query.traitInstances.all.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -187,8 +189,12 @@ function checkQuery(world, query, entity) { - if (!forbidden && !required && !or) return false; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - return true; - } - -@@ -222,6 +228,8 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const generationsLen = generations.length; - const trackingGroupsLen = trackingGroups.length; - if (traitInstancesAll.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generationsLen; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -233,8 +241,12 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const entityMask = genMasks ? genMasks[eid] | 0 : 0; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - let hasOrGroup = false; - let anyOrMatched = false; - for (let i = 0; i < trackingGroupsLen; i++) { -diff --git a/node_modules/koota/dist/index.cjs b/node_modules/koota/dist/index.cjs -index efa87da..7f3fedd 100644 ---- a/node_modules/koota/dist/index.cjs -+++ b/node_modules/koota/dist/index.cjs -@@ -222,6 +222,8 @@ function checkQuery(world, query, entity) { - const ctx = world[$internal]; - const eid = entity & ENTITY_ID_MASK; - if (query.traitInstances.all.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -233,8 +235,12 @@ function checkQuery(world, query, entity) { - if (!forbidden && !required && !or) return false; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - return true; - } - -@@ -654,6 +660,8 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const generationsLen = generations.length; - const trackingGroupsLen = trackingGroups.length; - if (traitInstancesAll.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generationsLen; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -665,8 +673,12 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const entityMask = genMasks ? genMasks[eid] | 0 : 0; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - let hasOrGroup = false; - let anyOrMatched = false; - for (let i = 0; i < trackingGroupsLen; i++) { -diff --git a/node_modules/koota/dist/react.cjs b/node_modules/koota/dist/react.cjs -index 3117293..6b99f96 100644 ---- a/node_modules/koota/dist/react.cjs -+++ b/node_modules/koota/dist/react.cjs -@@ -118,6 +118,8 @@ function checkQuery(world, query, entity) { - const ctx = world[$internal]; - const eid = entity & ENTITY_ID_MASK; - if (query.traitInstances.all.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generations.length; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -129,8 +131,12 @@ function checkQuery(world, query, entity) { - if (!forbidden && !required && !or) return false; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - return true; - } - -@@ -493,6 +499,8 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const generationsLen = generations.length; - const trackingGroupsLen = trackingGroups.length; - if (traitInstancesAll.length === 0) return false; -+ let staticHasOr = false; -+ let staticOrMatched = false; - for (let i = 0; i < generationsLen; i++) { - const generationId = generations[i]; - const bitmask = staticBitmasks[i]; -@@ -504,8 +512,12 @@ function checkQueryTracking(world, query, entity, eventType, eventGenerationId, - const entityMask = genMasks ? genMasks[eid] | 0 : 0; - if (forbidden && (entityMask & forbidden) !== 0) return false; - if (required && (entityMask & required) !== required) return false; -- if (or !== 0 && (entityMask & or) === 0) return false; -+ if (or !== 0) { -+ staticHasOr = true; -+ if ((entityMask & or) !== 0) staticOrMatched = true; -+ } - } -+ if (staticHasOr && !staticOrMatched) return false; - let hasOrGroup = false; - let anyOrMatched = false; - for (let i = 0; i < trackingGroupsLen; i++) {