diff --git a/src/app/App.tsx b/src/app/App.tsx
index e216e025..acf70c07 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -28,6 +28,7 @@ import { createImportAudioAssetCommand } from "../commands/import-audio-asset-co
import { createImportBackgroundImageAssetCommand } from "../commands/import-background-image-asset-command";
import { createImportModelAssetCommand } from "../commands/import-model-asset-command";
import { createAddPathPointCommand } from "../commands/add-path-point-command";
+import { createApplySplineRoadToTerrainCommand } from "../commands/apply-spline-road-to-terrain-command";
import { createDeletePathCommand } from "../commands/delete-path-command";
import { createDeletePathPointCommand } from "../commands/delete-path-point-command";
import { createDeletePathPointsCommand } from "../commands/delete-path-points-command";
@@ -9740,6 +9741,24 @@ export function App({
}
};
+ const handleApplyPathRoadToTerrain = () => {
+ if (selectedPath === null) {
+ setStatusMessage("Select a path before applying its road to terrain.");
+ return;
+ }
+
+ try {
+ store.executeCommand(
+ createApplySplineRoadToTerrainCommand({
+ pathId: selectedPath.id
+ })
+ );
+ setStatusMessage("Applied Path road to terrain.");
+ } catch (error) {
+ setStatusMessage(getErrorMessage(error));
+ }
+ };
+
const handlePathPointDraftChange = (
pointIndex: number,
axis: keyof Vec3Draft,
@@ -22147,10 +22166,23 @@ export function App({
))}
+
+
+
Road preview draws a temporary corridor along the resolved
- spline. Width affects the visible strip; shoulder and
- falloff are stored for the terrain-apply command next.
+ spline. Applying it patches terrain heights, terrain paint
+ weights when the material already exists on a terrain
+ layer, and foliage blocker masks. It does not generate a
+ road mesh yet.
diff --git a/src/commands/apply-spline-road-to-terrain-command.ts b/src/commands/apply-spline-road-to-terrain-command.ts
new file mode 100644
index 00000000..6ceca65e
--- /dev/null
+++ b/src/commands/apply-spline-road-to-terrain-command.ts
@@ -0,0 +1,119 @@
+import { createOpaqueId } from "../core/ids";
+import { cloneEditorSelection, type EditorSelection } from "../core/selection";
+import type { TerrainBrushPatch } from "../core/terrain-brush";
+import type { ToolMode } from "../core/tool-mode";
+import { getTerrains } from "../document/terrains";
+import { createSplineRoadTerrainPatches } from "../geometry/spline-road-terrain";
+
+import {
+ applyTerrainBrushPatchToDocument,
+ isTerrainBrushPatchEmpty
+} from "./apply-terrain-brush-patch-command";
+import type { CommandContext, EditorCommand } from "./command";
+
+interface ApplySplineRoadToTerrainCommandOptions {
+ pathId: string;
+ label?: string;
+}
+
+function clonePatch(patch: TerrainBrushPatch): TerrainBrushPatch {
+ return {
+ terrainId: patch.terrainId,
+ heightSamples: patch.heightSamples.map((entry) => ({ ...entry })),
+ paintWeights: patch.paintWeights.map((entry) => ({ ...entry })),
+ foliageMaskValues: patch.foliageMaskValues.map((entry) => ({ ...entry })),
+ foliageBlockerMaskValues: patch.foliageBlockerMaskValues.map((entry) => ({
+ ...entry
+ }))
+ };
+}
+
+function setPathSelection(pathId: string): EditorSelection {
+ return {
+ kind: "paths",
+ ids: [pathId]
+ };
+}
+
+function applyPatches(
+ context: CommandContext,
+ patches: readonly TerrainBrushPatch[],
+ direction: "forward" | "backward"
+) {
+ let nextDocument = context.getDocument();
+
+ for (const patch of patches) {
+ nextDocument = applyTerrainBrushPatchToDocument(
+ nextDocument,
+ patch,
+ direction
+ );
+ }
+
+ context.setDocument(nextDocument);
+}
+
+export function createApplySplineRoadToTerrainCommand(
+ options: ApplySplineRoadToTerrainCommandOptions
+): EditorCommand {
+ let patches: TerrainBrushPatch[] | null = null;
+ let previousSelection: EditorSelection | null = null;
+ let previousToolMode: ToolMode | null = null;
+
+ return {
+ id: createOpaqueId("command"),
+ label: options.label ?? "Apply spline road to terrain",
+ execute(context) {
+ const currentDocument = context.getDocument();
+ const path = currentDocument.paths[options.pathId];
+
+ if (path === undefined) {
+ throw new Error(`Path ${options.pathId} does not exist.`);
+ }
+
+ if (!path.road.enabled) {
+ throw new Error("Enable road preview before applying it to terrain.");
+ }
+
+ if (previousSelection === null) {
+ previousSelection = cloneEditorSelection(context.getSelection());
+ }
+
+ if (previousToolMode === null) {
+ previousToolMode = context.getToolMode();
+ }
+
+ if (patches === null) {
+ patches = createSplineRoadTerrainPatches({
+ path,
+ terrains: getTerrains(currentDocument.terrains)
+ })
+ .filter((patch) => !isTerrainBrushPatchEmpty(patch))
+ .map(clonePatch);
+ }
+
+ if (patches.length === 0) {
+ throw new Error("The spline road did not affect any enabled terrain.");
+ }
+
+ applyPatches(context, patches, "forward");
+ context.setSelection(setPathSelection(options.pathId));
+ context.setToolMode("select");
+ },
+ undo(context) {
+ if (patches === null) {
+ return;
+ }
+
+ applyPatches(context, [...patches].reverse(), "backward");
+
+ if (previousSelection !== null) {
+ context.setSelection(previousSelection);
+ }
+
+ if (previousToolMode !== null) {
+ context.setToolMode(previousToolMode);
+ }
+ }
+ };
+}