From e61634c3b97723ae38567b9cb2ab8240da9f35f6 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 12 Jul 2026 20:13:22 +0200 Subject: [PATCH] Neuer Bezugspunkt: Projekt nach Kataster-/3D-Import auf praktischen Ursprung verschieben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 1 + src/commands/cmds/georef.test.ts | 58 +++++++++ src/commands/cmds/georef.ts | 64 ++++++++++ src/commands/registry.ts | 4 + src/i18n/de.ts | 4 + src/i18n/en.ts | 4 + src/model/geoRebase.test.ts | 203 +++++++++++++++++++++++++++++++ src/model/geoRebase.ts | 120 ++++++++++++++++++ src/panels/SitePanel.tsx | 8 ++ src/panels/host.ts | 8 ++ 10 files changed, 474 insertions(+) create mode 100644 src/commands/cmds/georef.test.ts create mode 100644 src/commands/cmds/georef.ts create mode 100644 src/model/geoRebase.test.ts create mode 100644 src/model/geoRebase.ts diff --git a/src/App.tsx b/src/App.tsx index ff06b71..07423bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3546,6 +3546,7 @@ export default function App() { setProject((p) => ({ ...p, geoAnchor: anchor })); }, onImportDxf: () => dxfInputRef.current?.click(), + onStartGeoref: () => onRunCommand("georef"), // ── Element-Baum (Elemente-Panel) ──────────────────────────────────── onSelectScheduleRow: ( kind: ScheduleKind, diff --git a/src/commands/cmds/georef.test.ts b/src/commands/cmds/georef.test.ts new file mode 100644 index 0000000..c6d7a94 --- /dev/null +++ b/src/commands/cmds/georef.test.ts @@ -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(); + }); +}); diff --git a/src/commands/cmds/georef.ts b/src/commands/cmds/georef.ts new file mode 100644 index 0000000..401e1b4 --- /dev/null +++ b/src/commands/cmds/georef.ts @@ -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(), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 8ffcf80..2cb953b 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -30,6 +30,7 @@ import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; import { measureCommand } from "./cmds/measure"; import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline"; +import { georefCommand } from "./cmds/georef"; /** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ export const COMMANDS: Record = { @@ -62,6 +63,7 @@ export const COMMANDS: Record = { measure: measureCommand, sectionline: sectionLineCommand, viewline: viewLineCommand, + georef: georefCommand, }; /** @@ -110,6 +112,8 @@ export const ALIASES: Record = { ter: "terrain", schnittlinie: "sectionline", ansichtslinie: "viewline", + bezugspunkt: "georef", + nullpunkt: "georef", }; /** Liefert den Befehl zu einem exakten Namen (oder undefined). */ diff --git a/src/i18n/de.ts b/src/i18n/de.ts index c467e29..9402d64 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1122,6 +1122,8 @@ export const de = { "cmd.import.prompt": "DXF-Datei wählen …", "cmd.terrain.label": "Gelände", "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) ────────────────────────────────────────────── "nav.roomBalance": "Flächenbilanz", @@ -1273,6 +1275,8 @@ export const de = { // ── Standort-Import (geo.admin / OSM) ───────────────────────────────────── "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.close": "Schließen", "ctxImport.geoAnchor": "Georeferenziert ab: {label}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index fe1626a..d37f725 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1111,6 +1111,8 @@ export const en: Record = { "cmd.import.prompt": "Choose DXF file …", "cmd.terrain.label": "Terrain", "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). "nav.roomBalance": "Area balance", @@ -1261,6 +1263,8 @@ export const en: Record = { // Location import (geo.admin / OSM). "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.close": "Close", "ctxImport.geoAnchor": "Georeferenced from: {label}", diff --git a/src/model/geoRebase.test.ts b/src/model/geoRebase.test.ts new file mode 100644 index 0000000..f6b9c97 --- /dev/null +++ b/src/model/geoRebase.test.ts @@ -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(); + }); +}); diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts new file mode 100644 index 0000000..d8adcfb --- /dev/null +++ b/src/model/geoRebase.ts @@ -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, + }; +} diff --git a/src/panels/SitePanel.tsx b/src/panels/SitePanel.tsx index 27dc8ea..db83847 100644 --- a/src/panels/SitePanel.tsx +++ b/src/panels/SitePanel.tsx @@ -76,6 +76,14 @@ export function SitePanel() { > {t("site.importLocation")} + {ctxImportOpen && ( diff --git a/src/panels/host.ts b/src/panels/host.ts index 9595155..d1469b8 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -423,6 +423,14 @@ export interface PanelHostValue { ) => void; /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ 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) ─────────────────────────────────────── /**