diff --git a/packages/editor/src/EditorChrome.tsx b/packages/editor/src/EditorChrome.tsx index f58763541..1b4b6075a 100644 --- a/packages/editor/src/EditorChrome.tsx +++ b/packages/editor/src/EditorChrome.tsx @@ -1,45 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - collectDescendants, editorDocumentSize, - editorParentOf, extractEditorFragment, - findEditorNote, - findEditorPath, listEditorKinds, WELL_KNOWN_MARKER_KINDS, type EditorDocument, - type EditorPath, type EditorSession, - type EditorVolume, } from "@jgengine/core/editor/index"; -import { - readVegetationSettings, - vegetationFootprint, - VEGETATION_VOLUME_KIND, -} from "@jgengine/core/world/vegetation"; -import { editorDocumentBounds } from "@jgengine/core/editor/index"; -import type { Aabb } from "@jgengine/core/world/geometry"; -import { - createTerrainSnapshot, - editableTerrainFromSnapshot, - type EditableTerrain, - type SurfaceDelta, - type TerrainSurfaceRule, -} from "@jgengine/core/world/terraform"; +import { VEGETATION_VOLUME_KIND } from "@jgengine/core/world/vegetation"; import { scatterRegionEstimate, SCATTER_PATH_KIND } from "@jgengine/core/world/scatterRegion"; -import { - getSceneKind, - isSceneKind, - listSceneKinds, - parseParams, - type SceneKindObject, -} from "@jgengine/core/scene/sceneKinds"; -import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator"; -import { useGameContext } from "@jgengine/react/provider"; - -import { SchemaInspector, type MetaPatch } from "./SchemaInspector"; +import { listSceneKinds } from "@jgengine/core/scene/sceneKinds"; import { AssetBrowser, type EditorAssetEntry } from "./AssetBrowser"; import { CollectionsPanel } from "./CollectionsPanel"; @@ -47,18 +18,14 @@ import { OutlinerPanel } from "./OutlinerPanel"; import { PrefabsPanel } from "./PrefabsPanel"; import { buildOutlinerGroups } from "./outlinerModel"; import type { EditorHostApi, EditorPerfSample } from "./session"; -import { TERRAIN_MATERIALS, type EditorUiStore, type PlacementTool, type SnapMode, type TerrainBrushKind } from "./uiStore"; +import { type EditorUiStore, type PlacementTool, type SnapMode } from "./uiStore"; import { useF2Chord } from "./useF2Chord"; -import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector"; +import { BTN, MICRO } from "./chromeStyles"; +import { TerrainPanel } from "./TerrainPanel"; +import { InspectorPanel } from "./InspectorPanel"; const PERF_POLL_MS = 500; -const BTN = - "rounded-md bg-white/[0.04] px-2 py-1 text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 hover:text-neutral-100"; -const INPUT = - "rounded-md border border-white/10 bg-black/40 px-2 py-1 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60"; -const MICRO = "text-[9px] font-semibold uppercase tracking-[0.14em] text-neutral-500"; - type WorkspacePanel = "outliner" | "assets" | "collections" | "prefabs"; const ADD_VOLUME_ENTRIES: readonly { label: string; tool: PlacementTool }[] = [ @@ -127,426 +94,6 @@ function downloadText(filename: string, text: string): void { URL.revokeObjectURL(url); } -function NumberField({ - label, - value, - onCommit, - step = 1, -}: { - label: string; - value: number; - onCommit: (value: number) => void; - step?: number; -}) { - return ( - - ); -} - -function VegetationFields({ - volume, - onMeta, -}: { - volume: EditorVolume; - onMeta: (patch: Record, coalesce: string) => void; -}) { - const settings = readVegetationSettings(volume); - if (settings === null) return null; - const footprint = vegetationFootprint(volume); - const areaM2 = (footprint.maxX - footprint.minX) * (footprint.maxZ - footprint.minZ); - const sliderMax = settings.item === "grass" ? 12 : 1; - const estimated = Math.floor(areaM2 * settings.density); - return ( -
-
Vegetation
- - - onMeta({ density: Math.max(0, value) }, "veg:density")} /> - onMeta({ minScale: value }, "veg:minScale")} /> - onMeta({ maxScale: value }, "veg:maxScale")} /> - onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance")} /> - -
≈ {estimated.toLocaleString()} {settings.item === "grass" ? "blades" : "placements"} over {Math.round(areaM2).toLocaleString()} m²
-
- ); -} - -const TERRAIN_BRUSHES: readonly { kind: TerrainBrushKind; label: string; hint: string }[] = [ - { kind: "raise", label: "Raise", hint: "Push terrain up" }, - { kind: "lower", label: "Lower", hint: "Dig terrain down" }, - { kind: "smooth", label: "Smooth", hint: "Average toward neighbors" }, - { kind: "flatten", label: "Flatten", hint: "Level to a target height" }, - { kind: "noise", label: "Noise", hint: "Roughen with fractal detail" }, - { kind: "ramp", label: "Ramp", hint: "Drag a straight grade A→B" }, -]; - -function SliderRow({ - label, - value, - min, - max, - step, - onChange, - format, -}: { - label: string; - value: number; - min: number; - max: number; - step: number; - onChange: (value: number) => void; - format?: (value: number) => string; -}) { - return ( - - ); -} - -/** Sensible sculpt area when none is authored yet: the document footprint padded, or a 200m square. */ -function defaultTerrainBounds(document: Parameters[0]): Aabb { - const bounds = editorDocumentBounds(document); - if (bounds === null) return { minX: -100, minZ: -100, maxX: 100, maxZ: 100 }; - const pad = 40; - const minX = bounds.min.x - pad; - const minZ = bounds.min.z - pad; - const maxX = bounds.max.x + pad; - const maxZ = bounds.max.z + pad; - const span = Math.max(80, Math.min(400, Math.max(maxX - minX, maxZ - minZ))); - const cx = (minX + maxX) / 2; - const cz = (minZ + maxZ) / 2; - return { minX: cx - span / 2, minZ: cz - span / 2, maxX: cx + span / 2, maxZ: cz + span / 2 }; -} - -function SculptControls({ ui }: { ui: EditorUiStore }) { - const sculpt = ui.getState().sculpt; - return ( - <> -
- {TERRAIN_BRUSHES.map((brush) => ( - - ))} -
- {sculpt.brush === "ramp" ?
Drag from the low end to the high end to grade a slope.
: null} - ui.patchSculpt({ radius: value })} format={(v) => `${v.toFixed(1)}m`} /> - ui.patchSculpt({ strength: value })} /> - ui.patchSculpt({ spacing: value })} format={(v) => `${v.toFixed(2)}m`} /> -
- falloff -
- {(["smooth", "linear", "none"] as const).map((mode) => ( - - ))} -
-
-
- shape - - -
- {sculpt.brush === "flatten" ? ( - - ) : null} - {sculpt.brush === "noise" ? ( - - ) : null} - - ); -} - -function PaintControls({ session, ui }: { session: EditorSession; ui: EditorUiStore }) { - const paint = ui.getState().paint; - const document = session.getState().document; - - const paintDelta = (build: (terrain: EditableTerrain) => SurfaceDelta) => { - if (document.terrain === undefined) return; - const delta = build(editableTerrainFromSnapshot(document.terrain)); - if (delta.indices.length > 0) session.dispatch({ type: "paintTerrain", delta }); - }; - - return ( - <> -
- {TERRAIN_MATERIALS.map((material) => ( - - ))} -
-
Click/drag to paint · Alt-click to sample.
- ui.patchPaint({ radius: value })} format={(v) => `${v.toFixed(1)}m`} /> -
- shape - -
-
- - -
-
-
Auto rules
-
- - -
-
- - ); -} - -/** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */ -function TerrainPanel({ session, ui }: { session: EditorSession; ui: EditorUiStore }) { - const [, setTick] = useState(0); - useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]); - useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]); - const uiState = ui.getState(); - const document = session.getState().document; - const hasTerrain = document.terrain !== undefined; - - const createTerrain = () => { - session.dispatch({ type: "setTerrain", terrain: createTerrainSnapshot({ bounds: defaultTerrainBounds(document), cellSize: 2 }) }); - }; - - return ( -
-
-
Terrain
- -
- {!hasTerrain ? ( -
-

No terrain yet. Create an editable heightfield over the scene, then sculpt its shape and paint material layers on it.

- -
- ) : ( - <> -
- {(["sculpt", "paint"] as const).map((mode) => ( - - ))} -
- {uiState.terrainMode === "paint" ? : } -
- - -
- - )} -
- ); -} - -/** Builds the resolver-facing view of a document object for a registered scene kind's inspector. */ -function markerObject(marker: { id: string; kind: string; position: { x: number; y: number; z: number }; rotationY?: number; meta?: Record }): SceneKindObject { - return { id: marker.id, kind: marker.kind, position: marker.position, ...(marker.rotationY === undefined ? {} : { rotationY: marker.rotationY }), ...(marker.meta === undefined ? {} : { meta: marker.meta }) }; -} - -function volumeObject(volume: EditorVolume): SceneKindObject { - return { - id: volume.id, - kind: volume.kind, - center: volume.center, - ...(volume.halfExtents === undefined ? {} : { halfExtents: volume.halfExtents }), - ...(volume.radius === undefined ? {} : { radius: volume.radius }), - ...(volume.meta === undefined ? {} : { meta: volume.meta }), - }; -} - -function pathObject(path: EditorPath): SceneKindObject { - return { id: path.id, kind: path.kind, points: path.points.map((point) => ({ x: point.x, y: point.y, z: point.z })), ...(path.meta === undefined ? {} : { meta: path.meta }) }; -} - -/** Auto-generated inspector for a registered scene kind's params (schema-driven, no per-kind JSX). */ -function KindInspector({ object, meta, onMeta }: { object: SceneKindObject; meta: Record | undefined; onMeta: MetaPatch }) { - const definition = getSceneKind(object.kind); - if (definition === undefined) return null; - const note = definition.note?.(object, parseParams(definition.schema, object.meta)); - return ( - - ); -} - -/** Auto-generated inspector for a placed generator asset's params (building/bookcase/…). */ -function GeneratorInspector({ meta, onMeta }: { meta: Record | undefined; onMeta: MetaPatch }) { - const assetId = typeof meta?.["assetId"] === "string" ? (meta["assetId"] as string) : undefined; - const generator = assetId === undefined ? undefined : getAssetGenerator(assetId); - if (generator === undefined) return null; - return ; -} - -/** - * Tags an object as a gameplay spot with a clearance radius: scatter keeps foliage off it and the - * runtime ground flattens under it (via `clearanceZonesFrom` → `environment({ clearings })`). 0 = untagged. - */ -function ClearanceField({ - meta, - onMeta, -}: { - meta: Record | undefined; - onMeta: (patch: Record, coalesce: string) => void; -}) { - const value = typeof meta?.["clearance"] === "number" ? (meta["clearance"] as number) : 0; - return ( -
- onMeta({ clearance: Math.max(0, next) }, "clearance")} /> -
- ); -} - -/** Inspector row to parent the selected object under another (excludes itself and its descendants). */ -function ParentField({ session, id }: { session: EditorSession; id: string }) { - const document = session.getState().document; - const current = editorParentOf(document, id) ?? ""; - const banned = collectDescendants(document, [id]); - banned.add(id); - const labelOf = (node: { id: string; label?: string }) => node.label ?? node.id; - const candidates = [ - ...document.markers.map((m) => ({ id: m.id, label: labelOf(m) })), - ...document.volumes.map((v) => ({ id: v.id, label: labelOf(v) })), - ...document.paths.map((p) => ({ id: p.id, label: labelOf(p) })), - ...document.annotations.map((n) => ({ id: n.id, label: n.text.slice(0, 30) || n.id })), - ].filter((entry) => !banned.has(entry.id)); - return ( - - ); -} - -function KindColorFields({ - kind, - color, - onKind, - onColor, -}: { - kind: string; - color: string | undefined; - onKind: (kind: string) => void; - onColor: (color: string | undefined) => void; -}) { - return ( -
- - onColor(event.target.value)} - /> - {color !== undefined ? ( - - ) : null} -
- ); -} - type SaveState = "idle" | "saving" | "saved" | "error"; function useDocumentSave( @@ -575,166 +122,6 @@ function useDocumentSave( return { available: save !== undefined, dirty, saveState, saveError, doSave }; } -/** - * The right-hand inspector as an isolated, selector-subscribed panel. It reads only the - * document + selection slices (via `useStoreSelector`) and the ui store's `pathPoint` slice, so - * UI-only churn (gizmo mode, snapping, active tool) or unrelated document edits outside the - * selected object's slice no longer rerender it on `EditorChrome`'s own render tick. - * @internal — mounted by `EditorChrome` as a right-aside panel. - */ -function InspectorPanel({ session, ui, onClose }: { session: EditorSession; ui: EditorUiStore; onClose: () => void }) { - const document = useStoreSelector(session, (state) => state.document); - const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual); - const pathPoint = useStoreSelector(ui, (state) => state.pathPoint); - const ctx = useGameContext(); - - const selectedId = selection[0]; - const selectedMarker = document.markers.find((marker) => marker.id === selectedId); - const selectedVolume = document.volumes.find((volume) => volume.id === selectedId); - const selectedPath = selectedId === undefined ? undefined : findEditorPath(document, selectedId); - const selectedNote = selectedId === undefined ? undefined : findEditorNote(document, selectedId); - const documentMiss = - selectedId !== undefined && - selectedMarker === undefined && - selectedVolume === undefined && - selectedPath === undefined && - selectedNote === undefined; - const liveEntity = documentMiss ? ctx.scene.entity.get(selectedId) : null; - const liveObject = documentMiss && liveEntity === null ? ctx.scene.object.get(selectedId) : null; - - return ( -