Neuer Bezugspunkt: Projekt nach Kataster-/3D-Import auf praktischen Ursprung verschieben
Nach einem Standort-Import landet die Kontext-Geometrie dort, wo der gesuchte Ort war — das kann weit vom bisherigen Modell-Ursprung liegen. Neuer Befehl "georef" (Button in SitePanel) verschiebt das GESAMTE Projekt per Klick so, dass der gewählte Punkt zum neuen Ursprung wird; geoAnchor wandert automatisch mit, der reale LV95-Bezug bleibt exakt erhalten (rekonstruierbar, auch beim späteren Export). Bewusst nur Translation, keine Rotation — mehrere Felder (Roof.ridgeAxis, Column.rotation, Text-/Bild-Rotation) sind achsen-/winkel- gebunden und bräuchten bei einer Drehung eigene Sorgfalt.
This commit is contained in:
@@ -3546,6 +3546,7 @@ export default function App() {
|
|||||||
setProject((p) => ({ ...p, geoAnchor: anchor }));
|
setProject((p) => ({ ...p, geoAnchor: anchor }));
|
||||||
},
|
},
|
||||||
onImportDxf: () => dxfInputRef.current?.click(),
|
onImportDxf: () => dxfInputRef.current?.click(),
|
||||||
|
onStartGeoref: () => onRunCommand("georef"),
|
||||||
// ── Element-Baum (Elemente-Panel) ────────────────────────────────────
|
// ── Element-Baum (Elemente-Panel) ────────────────────────────────────
|
||||||
onSelectScheduleRow: (
|
onSelectScheduleRow: (
|
||||||
kind: ScheduleKind,
|
kind: ScheduleKind,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { georefCommand } from "./georef";
|
||||||
|
import type { CommandContext, Project, Vec2 } from "../types";
|
||||||
|
|
||||||
|
const ctx = {} as CommandContext;
|
||||||
|
const P = (x: number, y: number): Vec2 => ({ x, y });
|
||||||
|
|
||||||
|
function projectWith(wallStart: Vec2): Project {
|
||||||
|
return {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
layers: [],
|
||||||
|
drawingLevels: [],
|
||||||
|
walls: [
|
||||||
|
{
|
||||||
|
id: "W1",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: wallStart,
|
||||||
|
end: { x: wallStart.x + 5, y: wallStart.y },
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
drawings2d: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("georefCommand", () => {
|
||||||
|
it("Hover zeigt die resultierende Verschiebung im HUD (negativer Klickpunkt)", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [, r] = georefCommand.onMove(s0, P(120, 45), null, ctx);
|
||||||
|
expect(r.draft?.hud).toEqual({ at: P(120, 45), dx: -120, dy: -45 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Klick committet sofort: der geklickte Punkt wird zum neuen Ursprung", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [ns, r] = georefCommand.onInput(s0, { kind: "point", point: P(120, 45) }, ctx);
|
||||||
|
expect(r.done).toBe(true);
|
||||||
|
expect((ns as { phase: string }).phase).toBe("start");
|
||||||
|
const p = projectWith({ x: 120, y: 45 });
|
||||||
|
const out = r.commit!(p);
|
||||||
|
// Die Wand begann exakt am geklickten Punkt -> liegt danach auf (0,0).
|
||||||
|
expect(out.walls[0].start).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(out.walls[0].end).toEqual({ x: 5, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Esc/Rechtsklick auf leerem Zustand beendet ohne Commit", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [, r1] = georefCommand.onCancel(s0);
|
||||||
|
expect(r1.done).toBe(true);
|
||||||
|
expect(r1.commit).toBeUndefined();
|
||||||
|
const [, r2] = georefCommand.onConfirm(s0, ctx);
|
||||||
|
expect(r2.done).toBe(true);
|
||||||
|
expect(r2.commit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Neuer Bezugspunkt — verschiebt das GESAMTE Projekt (Wände, Decken, Dächer,
|
||||||
|
// Treppen, Räume, Stützen, Extrusionen, freie 2D-Geometrie, Kontext-Schicht,
|
||||||
|
// Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung (0,0)
|
||||||
|
// wird. Praktisch nach einem Kataster-/3D-Import (Kontext-Geometrie landet
|
||||||
|
// dort, wo der gesuchte Standort war — kann weit vom bisherigen Ursprung
|
||||||
|
// liegen, s. `Project.geoAnchor`): man klickt den gewünschten Bezugspunkt
|
||||||
|
// (z. B. eine Parzellenecke aus dem Import) und zeichnet danach mit kleinen,
|
||||||
|
// bequemen Koordinaten weiter. `geoAnchor` wandert automatisch mit (reine
|
||||||
|
// Translation, s. `model/geoRebase.ts`), der reale LV95-Bezug bleibt exakt
|
||||||
|
// erhalten — beim Export ist die Verschiebung jederzeit rekonstruierbar.
|
||||||
|
//
|
||||||
|
// Ein-Klick-Befehl: der erste (einzige) Punkt committet sofort.
|
||||||
|
|
||||||
|
import { translateProject } from "../../model/geoRebase";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface GeorefStart extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: GeorefStart = { phase: "start", lastPoint: null };
|
||||||
|
const idle = (): [CommandState, CommandResult] => [{ ...IDLE }, { draft: null, done: true }];
|
||||||
|
|
||||||
|
/** HUD am Cursor: zeigt die resultierende Verschiebung, bevor geklickt wird. */
|
||||||
|
function draft(point: Vec2): ToolDraft {
|
||||||
|
return {
|
||||||
|
preview: [],
|
||||||
|
vertices: [],
|
||||||
|
hud: { at: point, dx: -point.x, dy: -point.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const georefCommand: Command = {
|
||||||
|
name: "georef",
|
||||||
|
labelKey: "cmd.georef.label",
|
||||||
|
prompt: () => "cmd.georef.prompt",
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): GeorefStart => ({ ...IDLE }),
|
||||||
|
|
||||||
|
onInput: (state, input): [CommandState, CommandResult] => {
|
||||||
|
if (input.kind !== "point") return [state, { draft: null }];
|
||||||
|
const { x, y } = input.point;
|
||||||
|
return [
|
||||||
|
{ ...IDLE },
|
||||||
|
{
|
||||||
|
draft: null,
|
||||||
|
done: true,
|
||||||
|
commit: (p: Project) => translateProject(p, -x, -y),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => [state, { draft: draft(point) }],
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ import { importCommand } from "./cmds/import";
|
|||||||
import { terrainCommand } from "./cmds/terrain";
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
import { measureCommand } from "./cmds/measure";
|
import { measureCommand } from "./cmds/measure";
|
||||||
import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline";
|
import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline";
|
||||||
|
import { georefCommand } from "./cmds/georef";
|
||||||
|
|
||||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||||
export const COMMANDS: Record<string, Command> = {
|
export const COMMANDS: Record<string, Command> = {
|
||||||
@@ -62,6 +63,7 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
measure: measureCommand,
|
measure: measureCommand,
|
||||||
sectionline: sectionLineCommand,
|
sectionline: sectionLineCommand,
|
||||||
viewline: viewLineCommand,
|
viewline: viewLineCommand,
|
||||||
|
georef: georefCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +112,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
schnittlinie: "sectionline",
|
schnittlinie: "sectionline",
|
||||||
ansichtslinie: "viewline",
|
ansichtslinie: "viewline",
|
||||||
|
bezugspunkt: "georef",
|
||||||
|
nullpunkt: "georef",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
||||||
|
|||||||
@@ -1122,6 +1122,8 @@ export const de = {
|
|||||||
"cmd.import.prompt": "DXF-Datei wählen …",
|
"cmd.import.prompt": "DXF-Datei wählen …",
|
||||||
"cmd.terrain.label": "Gelände",
|
"cmd.terrain.label": "Gelände",
|
||||||
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
||||||
|
"cmd.georef.label": "Neuer Bezugspunkt",
|
||||||
|
"cmd.georef.prompt": "Neuer Nullpunkt (verschiebt das ganze Projekt):",
|
||||||
|
|
||||||
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
|
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
|
||||||
"nav.roomBalance": "Flächenbilanz",
|
"nav.roomBalance": "Flächenbilanz",
|
||||||
@@ -1273,6 +1275,8 @@ export const de = {
|
|||||||
|
|
||||||
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
|
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
|
||||||
"site.importLocation": "Standort importieren",
|
"site.importLocation": "Standort importieren",
|
||||||
|
"site.setGeoref": "Neuer Bezugspunkt",
|
||||||
|
"site.setGeoref.hint": "Klick im Grundriss verschiebt das ganze Projekt — der geklickte Punkt wird zum neuen Ursprung (0,0). Der reale Standort-Bezug bleibt erhalten.",
|
||||||
"ctxImport.title": "Standort importieren",
|
"ctxImport.title": "Standort importieren",
|
||||||
"ctxImport.close": "Schließen",
|
"ctxImport.close": "Schließen",
|
||||||
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
||||||
|
|||||||
@@ -1111,6 +1111,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.import.prompt": "Choose DXF file …",
|
"cmd.import.prompt": "Choose DXF file …",
|
||||||
"cmd.terrain.label": "Terrain",
|
"cmd.terrain.label": "Terrain",
|
||||||
"cmd.terrain.prompt": "Generate terrain from contours …",
|
"cmd.terrain.prompt": "Generate terrain from contours …",
|
||||||
|
"cmd.georef.label": "New reference point",
|
||||||
|
"cmd.georef.prompt": "New origin (shifts the whole project):",
|
||||||
|
|
||||||
// Area balance (SIA 416).
|
// Area balance (SIA 416).
|
||||||
"nav.roomBalance": "Area balance",
|
"nav.roomBalance": "Area balance",
|
||||||
@@ -1261,6 +1263,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// Location import (geo.admin / OSM).
|
// Location import (geo.admin / OSM).
|
||||||
"site.importLocation": "Import location",
|
"site.importLocation": "Import location",
|
||||||
|
"site.setGeoref": "New reference point",
|
||||||
|
"site.setGeoref.hint": "Click in the plan to shift the whole project — the clicked point becomes the new origin (0,0). The real-world reference is preserved.",
|
||||||
"ctxImport.title": "Import location",
|
"ctxImport.title": "Import location",
|
||||||
"ctxImport.close": "Close",
|
"ctxImport.close": "Close",
|
||||||
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Column, ExtrudedSolid, Project } from "./types";
|
||||||
|
import { sampleProject } from "./sampleProject";
|
||||||
|
import { translateProject } from "./geoRebase";
|
||||||
|
|
||||||
|
const DX = 100;
|
||||||
|
const DY = -50;
|
||||||
|
|
||||||
|
describe("translateProject", () => {
|
||||||
|
it("dx=dy=0 -> unverändertes Projekt (Referenzgleichheit)", () => {
|
||||||
|
expect(translateProject(sampleProject, 0, 0)).toBe(sampleProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Wand-Achsen (start/end)", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const w = sampleProject.walls[0];
|
||||||
|
const ow = out.walls.find((x) => x.id === w.id)!;
|
||||||
|
expect(ow.start).toEqual({ x: w.start.x + DX, y: w.start.y + DY });
|
||||||
|
expect(ow.end).toEqual({ x: w.end.x + DX, y: w.end.y + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Decken-Umriss inkl. Aussparungen", () => {
|
||||||
|
const withHole: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
ceilings: [
|
||||||
|
{
|
||||||
|
...sampleProject.ceilings![0],
|
||||||
|
openings: [
|
||||||
|
[{ x: 1, y: 1 }, { x: 2, y: 1 }, { x: 2, y: 2 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withHole, DX, DY);
|
||||||
|
const oc = out.ceilings![0];
|
||||||
|
const src = withHole.ceilings![0];
|
||||||
|
expect(oc.outline).toEqual(src.outline.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(oc.openings![0]).toEqual(src.openings![0].map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Dach-Umriss", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const r = sampleProject.roofs![0];
|
||||||
|
const or_ = out.roofs!.find((x) => x.id === r.id)!;
|
||||||
|
expect(or_.outline).toEqual(r.outline.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Treppe: start/center verschieben sich, dir (Richtungsvektor) bleibt unverändert", () => {
|
||||||
|
const withSpiral: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
stairs: [...sampleProject.stairs!, { ...sampleProject.stairs![0], id: "ST2", center: { x: 5, y: 5 } }],
|
||||||
|
};
|
||||||
|
const out = translateProject(withSpiral, DX, DY);
|
||||||
|
const st1 = out.stairs!.find((s) => s.id === "ST1")!;
|
||||||
|
const st2 = out.stairs!.find((s) => s.id === "ST2")!;
|
||||||
|
expect(st1.start).toEqual({ x: sampleProject.stairs![0].start.x + DX, y: sampleProject.stairs![0].start.y + DY });
|
||||||
|
expect(st1.dir).toEqual(sampleProject.stairs![0].dir); // Richtung bleibt
|
||||||
|
expect(st2.center).toEqual({ x: 5 + DX, y: 5 + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Raum: boundary + stampAnchor verschieben sich", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const r = sampleProject.rooms![0];
|
||||||
|
const or_ = out.rooms!.find((x) => x.id === r.id)!;
|
||||||
|
expect(or_.boundary).toEqual(r.boundary.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(or_.stampAnchor).toEqual({ x: r.stampAnchor!.x + DX, y: r.stampAnchor!.y + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stütze: position verschiebt sich, rotation bleibt unverändert", () => {
|
||||||
|
const col: Column = {
|
||||||
|
id: "C1",
|
||||||
|
type: "column",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "50",
|
||||||
|
position: { x: 2, y: 3 },
|
||||||
|
profile: { kind: "round", radius: 0.15 },
|
||||||
|
rotation: Math.PI / 4,
|
||||||
|
height: 2.6,
|
||||||
|
};
|
||||||
|
const withColumn: Project = { ...sampleProject, columns: [col] };
|
||||||
|
const out = translateProject(withColumn, DX, DY);
|
||||||
|
const oc = out.columns![0];
|
||||||
|
expect(oc.position).toEqual({ x: 2 + DX, y: 3 + DY });
|
||||||
|
expect(oc.rotation).toBe(col.rotation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Extrusion: points + Kreis-Mitte verschieben sich", () => {
|
||||||
|
const solid: ExtrudedSolid = {
|
||||||
|
id: "EX1",
|
||||||
|
type: "extrudedSolid",
|
||||||
|
levelId: "eg",
|
||||||
|
points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }],
|
||||||
|
height: 1,
|
||||||
|
circle: { center: { x: 5, y: 5 }, r: 1 },
|
||||||
|
};
|
||||||
|
const withSolid: Project = { ...sampleProject, extrudedSolids: [solid] };
|
||||||
|
const out = translateProject(withSolid, DX, DY);
|
||||||
|
const os = out.extrudedSolids![0];
|
||||||
|
expect(os.points).toEqual(solid.points.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(os.circle!.center).toEqual({ x: 5 + DX, y: 5 + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Drawing2D: alle Geometrie-Varianten verschieben ihre Punkte", () => {
|
||||||
|
const withDrawings: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
drawings2d: [
|
||||||
|
{ id: "D1", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 1 } } },
|
||||||
|
{ id: "D2", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }], closed: false } },
|
||||||
|
{ id: "D3", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "rect", min: { x: 0, y: 0 }, max: { x: 1, y: 1 } } },
|
||||||
|
{ id: "D4", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "circle", center: { x: 1, y: 1 }, r: 0.5 } },
|
||||||
|
{ id: "D5", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "arc", center: { x: 2, y: 2 }, r: 0.5, a0: 0, a1: 1 } },
|
||||||
|
{ id: "D6", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "text", at: { x: 3, y: 3 }, text: "x", height: 0.2, angle: 0.7 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withDrawings, DX, DY);
|
||||||
|
const byId = (id: string) => out.drawings2d.find((d) => d.id === id)!.geom;
|
||||||
|
expect(byId("D1")).toMatchObject({ a: { x: DX, y: DY }, b: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D2")).toMatchObject({ pts: [{ x: DX, y: DY }, { x: 1 + DX, y: DY }] });
|
||||||
|
expect(byId("D3")).toMatchObject({ min: { x: DX, y: DY }, max: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D4")).toMatchObject({ center: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D5")).toMatchObject({ center: { x: 2 + DX, y: 2 + DY }, a0: 0, a1: 1 }); // Winkel unverändert
|
||||||
|
expect(byId("D6")).toMatchObject({ at: { x: 3 + DX, y: 3 + DY }, angle: 0.7 }); // Winkel unverändert
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kontext-Mesh: nur x/y verschieben sich, Höhe (z) bleibt", () => {
|
||||||
|
const withContext: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CTX1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Test",
|
||||||
|
positions: [0, 0, 5, 2, 3, 7],
|
||||||
|
indices: [0, 1, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withContext, DX, DY);
|
||||||
|
const obj = out.context![0];
|
||||||
|
if (obj.type !== "importedMesh") throw new Error("expected importedMesh");
|
||||||
|
expect(obj.positions).toEqual([0 + DX, 0 + DY, 5, 2 + DX, 3 + DY, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Konturen-Satz: Punkte + Kreis-/Bogen-Mitte verschieben sich, Winkel bleiben", () => {
|
||||||
|
const withContours: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CS1",
|
||||||
|
type: "contourSet",
|
||||||
|
name: "Höhenlinien",
|
||||||
|
contours: [
|
||||||
|
{
|
||||||
|
z: 400,
|
||||||
|
pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }],
|
||||||
|
closed: false,
|
||||||
|
curve: { kind: "arc", cx: 1, cy: 1, r: 0.5, a0: 0, a1: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withContours, DX, DY);
|
||||||
|
const obj = out.context![0];
|
||||||
|
if (obj.type !== "contourSet") throw new Error("expected contourSet");
|
||||||
|
expect(obj.contours[0].pts).toEqual([{ x: DX, y: DY }, { x: 1 + DX, y: DY }]);
|
||||||
|
expect(obj.contours[0].curve).toEqual({ kind: "arc", cx: 1 + DX, cy: 1 + DY, r: 0.5, a0: 0, a1: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Schnittlinie (DrawingLevel.linePoints) verschiebt sich", () => {
|
||||||
|
const withSection: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
drawingLevels: [
|
||||||
|
...sampleProject.drawingLevels,
|
||||||
|
{
|
||||||
|
id: "SEC1",
|
||||||
|
name: "Schnitt A",
|
||||||
|
kind: "section",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 0, y: 0 }, { x: 5, y: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withSection, DX, DY);
|
||||||
|
const lvl = out.drawingLevels.find((l) => l.id === "SEC1")!;
|
||||||
|
expect(lvl.linePoints).toEqual([{ x: DX, y: DY }, { x: 5 + DX, y: DY }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geoAnchor.model verschiebt sich mit, lv95 bleibt unverändert (Bezug bleibt exakt erhalten)", () => {
|
||||||
|
const withAnchor: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 }, label: "Test" },
|
||||||
|
};
|
||||||
|
const out = translateProject(withAnchor, DX, DY);
|
||||||
|
expect(out.geoAnchor).toEqual({ lv95: { e: 2600000, n: 1200000 }, model: { x: DX, y: DY }, label: "Test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein geoAnchor gesetzt -> bleibt undefined", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
expect(out.geoAnchor).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Ursprungs-Verschiebung des GESAMTEN Projekts (Neuer Bezugspunkt) — reine,
|
||||||
|
// verlustfreie Translation aller Modell-Koordinaten um einen festen Vektor
|
||||||
|
// (dx,dy). Genutzt, um nach einem Kataster-/3D-Import (der weit weg vom
|
||||||
|
// Modell-Ursprung landen kann, s. `Project.geoAnchor`) einen neuen, praktischen
|
||||||
|
// Bezugspunkt zu setzen: die komplette Zeichnung wandert relativ zueinander
|
||||||
|
// unverändert, nur ihre absoluten Koordinaten verschieben sich. `geoAnchor`
|
||||||
|
// (falls gesetzt) wandert MIT, sodass der LV95-Bezug exakt erhalten bleibt —
|
||||||
|
// beim Export ist die Verschiebung damit jederzeit rekonstruierbar.
|
||||||
|
//
|
||||||
|
// Bewusst NUR Translation (keine Rotation): mehrere Felder im Modell sind an
|
||||||
|
// die Achsen gebunden (z. B. `Roof.ridgeAxis: "x"|"y"`) und mehrere Winkel-Felder
|
||||||
|
// (Column.rotation, Text-/Bild-Rotation, Tür-Schwenk) müssten bei einer Drehung
|
||||||
|
// mitgeführt werden — eine Rotation ist ein eigener, grösserer Schritt.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ContextObject,
|
||||||
|
Contour,
|
||||||
|
Project,
|
||||||
|
Vec2,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const shift = (p: Vec2, dx: number, dy: number): Vec2 => ({ x: p.x + dx, y: p.y + dy });
|
||||||
|
const shiftAll = (pts: Vec2[], dx: number, dy: number): Vec2[] => pts.map((p) => shift(p, dx, dy));
|
||||||
|
|
||||||
|
/** Kontur (Höhenlinie): Punkte + optionale Kreis-/Bogen-Mitte verschieben. */
|
||||||
|
function shiftContour(c: Contour, dx: number, dy: number): Contour {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
pts: shiftAll(c.pts, dx, dy),
|
||||||
|
curve: c.curve ? { ...c.curve, cx: c.curve.cx + dx, cy: c.curve.cy + dy } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontext-Objekt (importiertes Mesh/Terrain/Konturen-Satz) verschieben.
|
||||||
|
* `positions` ist ein flaches [x,y,z=Höhe, …]-Array — nur x/y (Grundriss)
|
||||||
|
* verschieben, die Höhe (z) bleibt unverändert.
|
||||||
|
*/
|
||||||
|
function shiftContextObject(obj: ContextObject, dx: number, dy: number): ContextObject {
|
||||||
|
if (obj.type === "contourSet") {
|
||||||
|
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
|
||||||
|
}
|
||||||
|
const positions = obj.positions.slice();
|
||||||
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
|
positions[i] += dx;
|
||||||
|
positions[i + 1] += dy;
|
||||||
|
}
|
||||||
|
return { ...obj, positions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt SÄMTLICHE Modell-Koordinaten eines Projekts um den festen Vektor
|
||||||
|
* (dx,dy) — Wände, Decken, Dächer, Öffnungen-Host (folgt automatisch der Wand),
|
||||||
|
* Treppen, Räume, Stützen, Extrusionen, freie 2D-Geometrie, Kontext-Schicht
|
||||||
|
* (Import/Terrain/Konturen), Schnitt-/Ansichtslinien sowie den Geo-Anker
|
||||||
|
* (`geoAnchor.model`, damit der LV95-Bezug exakt erhalten bleibt). Richtungs-
|
||||||
|
* Vektoren (z. B. `Stair.dir`) und Winkel-Felder bleiben unangetastet — eine
|
||||||
|
* reine Translation ändert keine Orientierung.
|
||||||
|
*/
|
||||||
|
export function translateProject(project: Project, dx: number, dy: number): Project {
|
||||||
|
if (dx === 0 && dy === 0) return project;
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
walls: project.walls.map((w) => ({
|
||||||
|
...w,
|
||||||
|
start: shift(w.start, dx, dy),
|
||||||
|
end: shift(w.end, dx, dy),
|
||||||
|
})),
|
||||||
|
ceilings: project.ceilings?.map((c) => ({
|
||||||
|
...c,
|
||||||
|
outline: shiftAll(c.outline, dx, dy),
|
||||||
|
openings: c.openings?.map((h) => shiftAll(h, dx, dy)),
|
||||||
|
})),
|
||||||
|
roofs: project.roofs?.map((r) => ({ ...r, outline: shiftAll(r.outline, dx, dy) })),
|
||||||
|
stairs: project.stairs?.map((s) => ({
|
||||||
|
...s,
|
||||||
|
start: shift(s.start, dx, dy),
|
||||||
|
center: s.center ? shift(s.center, dx, dy) : undefined,
|
||||||
|
})),
|
||||||
|
rooms: project.rooms?.map((r) => ({
|
||||||
|
...r,
|
||||||
|
boundary: shiftAll(r.boundary, dx, dy),
|
||||||
|
stampAnchor: r.stampAnchor ? shift(r.stampAnchor, dx, dy) : undefined,
|
||||||
|
})),
|
||||||
|
columns: project.columns?.map((c) => ({ ...c, position: shift(c.position, dx, dy) })),
|
||||||
|
extrudedSolids: project.extrudedSolids?.map((s) => ({
|
||||||
|
...s,
|
||||||
|
points: shiftAll(s.points, dx, dy),
|
||||||
|
circle: s.circle ? { ...s.circle, center: shift(s.circle.center, dx, dy) } : undefined,
|
||||||
|
})),
|
||||||
|
drawings2d: project.drawings2d.map((d) => {
|
||||||
|
const g = d.geom;
|
||||||
|
switch (g.shape) {
|
||||||
|
case "line":
|
||||||
|
return { ...d, geom: { ...g, a: shift(g.a, dx, dy), b: shift(g.b, dx, dy) } };
|
||||||
|
case "polyline":
|
||||||
|
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
|
||||||
|
case "rect":
|
||||||
|
return { ...d, geom: { ...g, min: shift(g.min, dx, dy), max: shift(g.max, dx, dy) } };
|
||||||
|
case "circle":
|
||||||
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "arc":
|
||||||
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "text":
|
||||||
|
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
context: project.context?.map((o) => shiftContextObject(o, dx, dy)),
|
||||||
|
drawingLevels: project.drawingLevels.map((lvl) =>
|
||||||
|
lvl.linePoints
|
||||||
|
? { ...lvl, linePoints: [shift(lvl.linePoints[0], dx, dy), shift(lvl.linePoints[1], dx, dy)] }
|
||||||
|
: lvl,
|
||||||
|
),
|
||||||
|
geoAnchor: project.geoAnchor
|
||||||
|
? { ...project.geoAnchor, model: shift(project.geoAnchor.model, dx, dy) }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -76,6 +76,14 @@ export function SitePanel() {
|
|||||||
>
|
>
|
||||||
{t("site.importLocation")}
|
{t("site.importLocation")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-btn"
|
||||||
|
title={t("site.setGeoref.hint")}
|
||||||
|
onClick={() => host.onStartGeoref()}
|
||||||
|
>
|
||||||
|
{t("site.setGeoref")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ctxImportOpen && (
|
{ctxImportOpen && (
|
||||||
|
|||||||
@@ -423,6 +423,14 @@ export interface PanelHostValue {
|
|||||||
) => void;
|
) => void;
|
||||||
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
||||||
onImportDxf: () => void;
|
onImportDxf: () => void;
|
||||||
|
/**
|
||||||
|
* Startet den Befehl „Neuer Bezugspunkt" (`georef`, Command-Engine): der
|
||||||
|
* nächste Klick im Grundriss verschiebt das GESAMTE Projekt so, dass der
|
||||||
|
* geklickte Punkt zum neuen Modell-Ursprung (0,0) wird — `geoAnchor` wandert
|
||||||
|
* automatisch mit (s. `model/geoRebase.ts`). Praktisch nach einem Kataster-/
|
||||||
|
* 3D-Import, der weit weg vom bisherigen Ursprung landen kann.
|
||||||
|
*/
|
||||||
|
onStartGeoref: () => void;
|
||||||
|
|
||||||
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
|
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user