Schnitt/Ansicht: SectionOutput aus render3d an die Zeichenebenen anbinden
- render3d/web.rs: GPU-freies WASM-Binding cut_section_json(model_json, p, n)
→ JSON {cutPolygons, visibleEdges, hiddenEdges} in (u,v)-Metern.
- src/plan/toSection.ts (neu): SectionOutput-Typen, sectionPlaneFromLevel()
leitet die Schnittebene aus linePoints/directionSign ab, computeSection()
flacht über projectToModel3d ab und ruft das Binding.
- src/engine/engine3d.ts (neu): geteilter memoisierter pkg3d-Loader, damit
3D-Viewport und Schnittpfad EINE mod.default()-Init teilen.
- generatePlan.ts: generateSectionPlan() übersetzt SectionOutput in
bestehende Primitive (Cut = polygon mit Poché-Schraffur, sichtbare Kante =
line solid, verdeckte Kante = line dashed) — keine neue Primitive-Art nötig.
- App.tsx: SectionPlanView ersetzt den Stub für Ebenen vom Typ Schnitt/Ansicht;
rendert dasselbe PlanView, mit lokalisierten Hinweisen (lädt/keine Linie/Fehler).
- i18n de/en: section.*-Schlüssel.
- Öffnungen erscheinen korrekt (Brüstung/Sturz-Teilrechtecke) über die
projectToModel3d-Teilkörper-Zerlegung.
Gates: tsc -b 0, build grün (WASM = eigener Lazy-Chunk), render3d cargo test 29/29,
wasm32 --features web check grün.
This commit is contained in:
@@ -25,6 +25,7 @@ use wasm_bindgen::prelude::*;
|
|||||||
use web_sys::HtmlCanvasElement;
|
use web_sys::HtmlCanvasElement;
|
||||||
|
|
||||||
use crate::gpu::Renderer;
|
use crate::gpu::Renderer;
|
||||||
|
use crate::section::{cut_section, SectionPlane};
|
||||||
use crate::types::{Camera, Projection, SlabInput, WallInput};
|
use crate::types::{Camera, Projection, SlabInput, WallInput};
|
||||||
|
|
||||||
/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
|
/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
|
||||||
@@ -37,6 +38,41 @@ struct ModelInput {
|
|||||||
slabs: Vec<SlabInput>,
|
slabs: Vec<SlabInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Berechnet einen Schnitt/eine Ansicht (`section::cut_section`) gegen das Modell
|
||||||
|
/// und gibt das `SectionOutput` als JSON-String zurueck. GPU-frei — reine
|
||||||
|
/// Prismen-Arithmetik (siehe `section.rs`); braucht KEINEN `WebModelRenderer`
|
||||||
|
/// (kein WebGPU-Kontext), nur das geladene WASM-Modul. Aufruf aus JS:
|
||||||
|
///
|
||||||
|
/// ```js
|
||||||
|
/// const json = mod.cut_section_json(
|
||||||
|
/// JSON.stringify({ walls, slabs }), // gleiches Format wie set_model
|
||||||
|
/// px, py, pz, // Punkt auf der Schnittebene (world-Meter, Y-up)
|
||||||
|
/// nx, ny, nz, // Ebenennormale = Blickrichtung (wird normalisiert)
|
||||||
|
/// );
|
||||||
|
/// const out = JSON.parse(json); // { cutPolygons, visibleEdges, hiddenEdges }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Koordinaten der Ausgabe: (u, v) in Metern — `u` horizontal entlang der
|
||||||
|
/// Ebene, `v = world.y` (absolute Hoehe). Siehe `section.rs`-Moduldoc.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn cut_section_json(
|
||||||
|
model_json: &str,
|
||||||
|
px: f32,
|
||||||
|
py: f32,
|
||||||
|
pz: f32,
|
||||||
|
nx: f32,
|
||||||
|
ny: f32,
|
||||||
|
nz: f32,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let model: ModelInput = serde_json::from_str(model_json)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
||||||
|
let plane = SectionPlane::new([px, py, pz], [nx, ny, nz]);
|
||||||
|
let out = cut_section(&plane, &model.walls, &model.slabs);
|
||||||
|
serde_json::to_string(&out)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("SectionOutput serialisieren: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
|
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
|
||||||
/// und die aktuelle Kamera. Wird per `WebModelRenderer.new(canvas)` aus JS erzeugt.
|
/// und die aktuelle Kamera. Wird per `WebModelRenderer.new(canvas)` aus JS erzeugt.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
+190
-12
@@ -35,8 +35,10 @@ import type {
|
|||||||
Vec2,
|
Vec2,
|
||||||
Wall,
|
Wall,
|
||||||
} from "./model/types";
|
} from "./model/types";
|
||||||
import { generatePlan } from "./plan/generatePlan";
|
import { generatePlan, generateSectionPlan } from "./plan/generatePlan";
|
||||||
import type { CategoryDisplayResolver } from "./plan/generatePlan";
|
import type { CategoryDisplayResolver } from "./plan/generatePlan";
|
||||||
|
import { computeSection } from "./plan/toSection";
|
||||||
|
import type { SectionOutput } from "./plan/toSection";
|
||||||
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
||||||
import { parseDxf } from "./io/dxfParser";
|
import { parseDxf } from "./io/dxfParser";
|
||||||
import type { DxfImportResult } from "./io/dxfParser";
|
import type { DxfImportResult } from "./io/dxfParser";
|
||||||
@@ -3836,7 +3838,40 @@ function Content({
|
|||||||
onEdit3dEnd: () => void;
|
onEdit3dEnd: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (level.kind === "section" || level.kind === "elevation") {
|
if (level.kind === "section" || level.kind === "elevation") {
|
||||||
return <StubView level={level} />;
|
return (
|
||||||
|
<main className="content">
|
||||||
|
<SectionPlanView
|
||||||
|
project={project}
|
||||||
|
level={level}
|
||||||
|
hairline={hairline}
|
||||||
|
mono={mono}
|
||||||
|
planViewRef={planViewRef}
|
||||||
|
onCursor={onCursor}
|
||||||
|
onZoom={onZoom}
|
||||||
|
onScale={onScale}
|
||||||
|
selectedWallIds={selectedWallIds}
|
||||||
|
selectedCeilingIds={selectedCeilingIds}
|
||||||
|
selectedOpeningIds={selectedOpeningIds}
|
||||||
|
selectedStairIds={selectedStairIds}
|
||||||
|
selectedRoomIds={selectedRoomIds}
|
||||||
|
roomStamp={roomStamp}
|
||||||
|
onStampMove={onStampMove}
|
||||||
|
onStampEdit={onStampEdit}
|
||||||
|
onPlanSelect={onPlanSelect}
|
||||||
|
onPlanMarquee={onPlanMarquee}
|
||||||
|
onSegmentCut={onSegmentCut}
|
||||||
|
onPlanContextMenu={onPlanContextMenu}
|
||||||
|
activeTool={activeTool}
|
||||||
|
toolInputActive={toolInputActive}
|
||||||
|
toolHandlers={toolHandlers}
|
||||||
|
grips={grips}
|
||||||
|
edgeGrips={edgeGrips}
|
||||||
|
selectedDrawingId={selectedDrawingId}
|
||||||
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
|
gripHandlers={gripHandlers}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert
|
// Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert
|
||||||
@@ -4154,18 +4189,161 @@ function LevelPlanView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platzhalter für Schnitt/Ansicht (kommt in einer späteren Phase). */
|
/**
|
||||||
function StubView({ level }: { level: DrawingLevel }) {
|
* Schnitt-/Ansichts-Ebene: der render3d-Schnitt-Extraktor (WASM, plan/toSection)
|
||||||
return (
|
* durchschneidet das Modell entlang der Schnittlinie der Ebene und übersetzt das
|
||||||
<main className="content">
|
* Ergebnis (Cut-Polygone + sichtbare/verdeckte Kanten) über generateSectionPlan
|
||||||
|
* in Plan-Primitive, die dieselbe PlanView wie ein Grundriss zeichnet.
|
||||||
|
*
|
||||||
|
* Die Berechnung ist asynchron (WASM lazy geladen); bis das Ergebnis da ist,
|
||||||
|
* bzw. wenn die Ebene keine Schnittlinie trägt oder das WASM-Modul den Export
|
||||||
|
* (noch) nicht kennt, wird ein Hinweis eingeblendet.
|
||||||
|
*/
|
||||||
|
function SectionPlanView({
|
||||||
|
project,
|
||||||
|
level,
|
||||||
|
hairline,
|
||||||
|
mono,
|
||||||
|
planViewRef,
|
||||||
|
onCursor,
|
||||||
|
onZoom,
|
||||||
|
onScale,
|
||||||
|
selectedWallIds,
|
||||||
|
selectedCeilingIds,
|
||||||
|
selectedOpeningIds,
|
||||||
|
selectedStairIds,
|
||||||
|
selectedRoomIds,
|
||||||
|
roomStamp,
|
||||||
|
onStampMove,
|
||||||
|
onStampEdit,
|
||||||
|
onPlanSelect,
|
||||||
|
onPlanMarquee,
|
||||||
|
onSegmentCut,
|
||||||
|
onPlanContextMenu,
|
||||||
|
activeTool,
|
||||||
|
toolInputActive,
|
||||||
|
toolHandlers,
|
||||||
|
grips,
|
||||||
|
edgeGrips,
|
||||||
|
selectedDrawingId,
|
||||||
|
selectedDrawingIds,
|
||||||
|
gripHandlers,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
level: DrawingLevel;
|
||||||
|
hairline: boolean;
|
||||||
|
mono: boolean;
|
||||||
|
planViewRef: React.RefObject<PlanViewHandle>;
|
||||||
|
onCursor: (model: Vec2 | null) => void;
|
||||||
|
onZoom: (percent: number) => void;
|
||||||
|
onScale: (n: number) => void;
|
||||||
|
selectedWallIds: string[];
|
||||||
|
selectedCeilingIds: string[];
|
||||||
|
selectedOpeningIds: string[];
|
||||||
|
selectedStairIds: string[];
|
||||||
|
selectedRoomIds: string[];
|
||||||
|
roomStamp: { roomId: string; anchor: Vec2 } | null;
|
||||||
|
onStampMove: (roomId: string, delta: Vec2) => void;
|
||||||
|
onStampEdit: (roomId: string) => void;
|
||||||
|
onPlanSelect: (sel: PlanSelection) => void;
|
||||||
|
onPlanMarquee: (sel: MarqueeSelection) => void;
|
||||||
|
onSegmentCut: (drawingId: string, model: Vec2) => void;
|
||||||
|
onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2 }) => void;
|
||||||
|
activeTool: ToolId;
|
||||||
|
toolInputActive: boolean;
|
||||||
|
toolHandlers: ToolHandlers;
|
||||||
|
grips: Vec2[];
|
||||||
|
edgeGrips: EdgeGrip[];
|
||||||
|
selectedDrawingId: string | null;
|
||||||
|
selectedDrawingIds?: string[];
|
||||||
|
gripHandlers: GripHandlers;
|
||||||
|
}) {
|
||||||
|
const [output, setOutput] = useState<SectionOutput | null>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">(
|
||||||
|
"loading",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
setStatus("loading");
|
||||||
|
setOutput(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const out = await computeSection(project, level);
|
||||||
|
if (disposed) return;
|
||||||
|
if (!out) {
|
||||||
|
setStatus("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOutput(out);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("render3d-Schnitt fehlgeschlagen:", e);
|
||||||
|
if (!disposed) setStatus("error");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [project, level]);
|
||||||
|
|
||||||
|
const plan = useMemo(
|
||||||
|
() => (output ? generateSectionPlan(output, mono) : null),
|
||||||
|
[output, mono],
|
||||||
|
);
|
||||||
|
|
||||||
|
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
|
||||||
|
|
||||||
|
if (!plan || status !== "ready") {
|
||||||
|
const note =
|
||||||
|
status === "empty"
|
||||||
|
? t("section.empty", { kind: kindLabel })
|
||||||
|
: status === "error"
|
||||||
|
? t("section.error", { kind: kindLabel })
|
||||||
|
: t("section.loading", { kind: kindLabel });
|
||||||
|
return (
|
||||||
<div className="placeholder">
|
<div className="placeholder">
|
||||||
<strong>{level.name}</strong>
|
<strong>{level.name}</strong>
|
||||||
<span>
|
<span>{note}</span>
|
||||||
{t("stub.note", {
|
|
||||||
kind: level.kind === "section" ? t("stub.section") : t("stub.elevation"),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="pane">
|
||||||
|
<div className="pane-title">
|
||||||
|
{t("content.sectionTitle", { name: level.name, kind: kindLabel })}
|
||||||
|
</div>
|
||||||
|
<PlanView
|
||||||
|
ref={planViewRef}
|
||||||
|
plan={plan}
|
||||||
|
resetKey={level.id}
|
||||||
|
onCursor={onCursor}
|
||||||
|
onZoom={onZoom}
|
||||||
|
onScale={onScale}
|
||||||
|
selectedWallIds={selectedWallIds}
|
||||||
|
selectedCeilingIds={selectedCeilingIds}
|
||||||
|
selectedOpeningIds={selectedOpeningIds}
|
||||||
|
selectedStairIds={selectedStairIds}
|
||||||
|
selectedRoomIds={selectedRoomIds}
|
||||||
|
roomStamp={roomStamp}
|
||||||
|
onStampMove={onStampMove}
|
||||||
|
onStampEdit={onStampEdit}
|
||||||
|
onSelect={onPlanSelect}
|
||||||
|
onMarquee={onPlanMarquee}
|
||||||
|
onSegmentCut={onSegmentCut}
|
||||||
|
onContextMenu={onPlanContextMenu}
|
||||||
|
activeTool={activeTool}
|
||||||
|
toolInputActive={toolInputActive}
|
||||||
|
toolHandlers={toolHandlers}
|
||||||
|
grips={grips}
|
||||||
|
edgeGrips={edgeGrips}
|
||||||
|
hairline={hairline}
|
||||||
|
selectedDrawingId={selectedDrawingId}
|
||||||
|
selectedDrawingIds={selectedDrawingIds}
|
||||||
|
gripHandlers={gripHandlers}
|
||||||
|
/>
|
||||||
|
<div className="plan-legend">{t("section.legend", { kind: kindLabel })}</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Gemeinsamer Lade-Einstieg für das render3d-WASM-Modul (pkg3d, gebaut über
|
||||||
|
// `npm run build:engine3d`). EINMAL geladen + initialisiert und memoisiert:
|
||||||
|
// `mod.default()` (der wasm-bindgen-Init) darf nur EINMAL laufen — ein zweiter
|
||||||
|
// Init würde die Closures des ersten verwerfen („closure invoked recursively or
|
||||||
|
// after being dropped"). Sowohl der 3D-Viewport (useWasm3dRenderer) als auch die
|
||||||
|
// Schnitt-/Ansichts-Pipeline (plan/toSection) teilen sich daher DIESE Instanz.
|
||||||
|
//
|
||||||
|
// Bewusst schwach typisiert (`any`): das echte `.d.ts` entsteht erst beim
|
||||||
|
// wasm-Build; die Aufrufer legen ihre eigene Minimal-Schnittstelle darüber.
|
||||||
|
|
||||||
|
let enginePromise: Promise<any> | null = null;
|
||||||
|
|
||||||
|
/** Lädt das WASM-Modul einmalig und gibt das initialisierte Modul zurück. */
|
||||||
|
export function loadEngine3d(): Promise<any> {
|
||||||
|
if (!enginePromise) {
|
||||||
|
enginePromise = (async () => {
|
||||||
|
const mod: any = await import("./pkg3d/render3d.js");
|
||||||
|
await mod.default(); // lädt render3d_bg.wasm
|
||||||
|
return mod;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return enginePromise;
|
||||||
|
}
|
||||||
@@ -397,6 +397,11 @@ export const de = {
|
|||||||
"content.drawingTitle": "Zeichnung · {name}",
|
"content.drawingTitle": "Zeichnung · {name}",
|
||||||
"content.drawingCanvas": "Zeichenfläche",
|
"content.drawingCanvas": "Zeichenfläche",
|
||||||
"content.drawingLegend": "Freie 2D-Zeichnung",
|
"content.drawingLegend": "Freie 2D-Zeichnung",
|
||||||
|
"content.sectionTitle": "{kind} · {name}",
|
||||||
|
"section.loading": "{kind} wird berechnet …",
|
||||||
|
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
|
||||||
|
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
|
||||||
|
"section.legend": "{kind} · abgeleitete Projektion (render3d)",
|
||||||
"props.floorHeight": "Geschosshöhe",
|
"props.floorHeight": "Geschosshöhe",
|
||||||
"props.cutHeight": "Schnitthöhe",
|
"props.cutHeight": "Schnitthöhe",
|
||||||
"props.okff": "OKFF",
|
"props.okff": "OKFF",
|
||||||
|
|||||||
@@ -394,6 +394,11 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"content.drawingTitle": "Drawing · {name}",
|
"content.drawingTitle": "Drawing · {name}",
|
||||||
"content.drawingCanvas": "Drawing canvas",
|
"content.drawingCanvas": "Drawing canvas",
|
||||||
"content.drawingLegend": "Free 2D drawing",
|
"content.drawingLegend": "Free 2D drawing",
|
||||||
|
"content.sectionTitle": "{kind} · {name}",
|
||||||
|
"section.loading": "Computing {kind} …",
|
||||||
|
"section.empty": "{kind} — no section line set in the plan",
|
||||||
|
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
|
||||||
|
"section.legend": "{kind} · derived projection (render3d)",
|
||||||
"props.floorHeight": "Floor height",
|
"props.floorHeight": "Floor height",
|
||||||
"props.cutHeight": "Cut height",
|
"props.cutHeight": "Cut height",
|
||||||
"props.okff": "FFL",
|
"props.okff": "FFL",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import type { Line } from "../model/geometry";
|
|||||||
export type DetailLevel = "grob" | "mittel" | "fein";
|
export type DetailLevel = "grob" | "mittel" | "fein";
|
||||||
import { computeJoins } from "../model/joins";
|
import { computeJoins } from "../model/joins";
|
||||||
import type { WallCuts } from "../model/joins";
|
import type { WallCuts } from "../model/joins";
|
||||||
|
import type { SectionOutput } from "./toSection";
|
||||||
|
|
||||||
/** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */
|
/** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */
|
||||||
const POCHE_STROKE = "#2b3039";
|
const POCHE_STROKE = "#2b3039";
|
||||||
@@ -401,6 +402,121 @@ export function generatePlan(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schnitt/Ansicht (abgeleitete Projektion aus render3d::section) ──────────────
|
||||||
|
// Die Rohdaten (Cut-Polygone + sichtbare/verdeckte projizierte Kanten) liefert
|
||||||
|
// der WASM-Extraktor (plan/toSection.ts → section.rs). Hier werden sie 1:1 in
|
||||||
|
// die BESTEHENDEN Plan-Primitive übersetzt — kein neues Primitiv nötig:
|
||||||
|
// • Cut-Polygon → `polygon` (kräftige Poché-Umrisslinie + Schnitt-Schraffur),
|
||||||
|
// • sichtbare Kante → `line` (durchgezogen),
|
||||||
|
// • verdeckte Kante → `line` (gestrichelt).
|
||||||
|
// Damit rendern PlanView (SVG) UND toRenderScene (Engine/WASM-2D) den Schnitt
|
||||||
|
// ohne jede Sonderbehandlung, mit voller Papier-mm-/Parität-Infrastruktur.
|
||||||
|
|
||||||
|
/** Tinte der Schnitt-Poché-Umrisslinie + Schraffur (dunkles Grau). */
|
||||||
|
const SECTION_INK = "#1f242c";
|
||||||
|
/** Tinte der sichtbaren Ansichtskanten. */
|
||||||
|
const SECTION_VISIBLE_INK = "#2b3039";
|
||||||
|
/** Tinte der verdeckten (gestrichelten) Ansichtskanten (etwas heller). */
|
||||||
|
const SECTION_HIDDEN_INK = "#8a929c";
|
||||||
|
/** Strichstärke der Cut-Poché-Umrisslinie (mm Papier, kräftig wie eine Wand). */
|
||||||
|
const SECTION_CUT_OUTLINE_MM = 0.35;
|
||||||
|
/** Strichstärke sichtbarer Ansichtskanten (mm Papier). */
|
||||||
|
const SECTION_VISIBLE_MM = 0.18;
|
||||||
|
/** Strichstärke verdeckter Ansichtskanten (mm Papier, dünner). */
|
||||||
|
const SECTION_HIDDEN_MM = 0.13;
|
||||||
|
/** Strichmuster verdeckter Kanten (mm Papier). */
|
||||||
|
const SECTION_HIDDEN_DASH: number[] = [0.12, 0.08];
|
||||||
|
|
||||||
|
/** Schnitt-Schraffur der Cut-Flächen (diagonale Poché wie ein Wandschnitt). */
|
||||||
|
const SECTION_HATCH: HatchRender = {
|
||||||
|
pattern: "diagonal",
|
||||||
|
scale: 1,
|
||||||
|
angle: 45,
|
||||||
|
color: SECTION_INK,
|
||||||
|
lineWeight: 0.13,
|
||||||
|
dash: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** RGB (0..1) → 6-stelliges Hex, für die Cut-Poché-Füllfarbe. */
|
||||||
|
function rgbToHex(rgb: [number, number, number]): string {
|
||||||
|
const c = (v: number) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(v * 255)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `#${c(rgb[0])}${c(rgb[1])}${c(rgb[2])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus einem {@link SectionOutput} (render3d::section, (u, v)-Meter) den
|
||||||
|
* Plan eines Schnitts/einer Ansicht. `u` wird zur Plan-X, `v` (absolute Höhe)
|
||||||
|
* zur Plan-Y — die PlanView zeigt Y nach oben, also erscheint der Schnitt
|
||||||
|
* aufrecht. Rein darstellend; kennt das Projekt nicht.
|
||||||
|
*/
|
||||||
|
export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
|
||||||
|
const primitives: Primitive[] = [];
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
const acc = (x: number, y: number) => {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cut-Polygone: gefüllte Poché-Fläche (Bauteilfarbe) + Schnitt-Schraffur +
|
||||||
|
// kräftige Umrisslinie — wie eine geschnittene Wand im Grundriss.
|
||||||
|
for (const cp of output.cutPolygons) {
|
||||||
|
if (cp.pts.length < 3) continue;
|
||||||
|
const pts: Vec2[] = cp.pts.map(([u, v]) => {
|
||||||
|
acc(u, v);
|
||||||
|
return { x: u, y: v };
|
||||||
|
});
|
||||||
|
// Mono: reines Weiss statt Bauteilfarbe (nur Umriss + Schraffur tragen).
|
||||||
|
primitives.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: mono ? "#ffffff" : rgbToHex(cp.color),
|
||||||
|
stroke: SECTION_INK,
|
||||||
|
strokeWidthMm: SECTION_CUT_OUTLINE_MM,
|
||||||
|
hatch: SECTION_HATCH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdeckte Kanten zuerst (liegen unter den sichtbaren), gestrichelt.
|
||||||
|
for (const e of output.hiddenEdges) {
|
||||||
|
acc(e.a[0], e.a[1]);
|
||||||
|
acc(e.b[0], e.b[1]);
|
||||||
|
primitives.push({
|
||||||
|
kind: "line",
|
||||||
|
a: { x: e.a[0], y: e.a[1] },
|
||||||
|
b: { x: e.b[0], y: e.b[1] },
|
||||||
|
cls: "section-hidden",
|
||||||
|
weightMm: SECTION_HIDDEN_MM,
|
||||||
|
dash: SECTION_HIDDEN_DASH,
|
||||||
|
color: SECTION_HIDDEN_INK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sichtbare Ansichtskanten, durchgezogen.
|
||||||
|
for (const e of output.visibleEdges) {
|
||||||
|
acc(e.a[0], e.a[1]);
|
||||||
|
acc(e.b[0], e.b[1]);
|
||||||
|
primitives.push({
|
||||||
|
kind: "line",
|
||||||
|
a: { x: e.a[0], y: e.a[1] },
|
||||||
|
b: { x: e.b[0], y: e.b[1] },
|
||||||
|
cls: "section-visible",
|
||||||
|
weightMm: SECTION_VISIBLE_MM,
|
||||||
|
color: SECTION_VISIBLE_INK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(minX)) return { primitives, bounds: { minX: 0, minY: 0, maxX: 1, maxY: 1 } };
|
||||||
|
return { primitives, bounds: { minX, minY, maxX, maxY } };
|
||||||
|
}
|
||||||
|
|
||||||
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
|
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
|
||||||
const CONTEXT_LINE_COLOR = "#9aa3ad";
|
const CONTEXT_LINE_COLOR = "#9aa3ad";
|
||||||
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
|
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Schnitt-/Ansichts-Pipeline: bindet den analytischen render3d-Schnitt-Extraktor
|
||||||
|
// (`section.rs`, WASM-Export `cut_section_json`) an das Dokumentmodell an.
|
||||||
|
//
|
||||||
|
// Ablauf:
|
||||||
|
// 1. Aus einer Zeichnungsebene (DrawingLevel, kind "section"/"elevation") wird
|
||||||
|
// eine Schnittebene abgeleitet — die Grundriss-Schnittlinie `linePoints`
|
||||||
|
// plus `directionSign` (Blickrichtung).
|
||||||
|
// 2. Das Projekt wird über `projectToModel3d` (dieselbe Wand-/Decken-Zerlegung
|
||||||
|
// wie der 3D-Viewport) zu Wänden + Decken geflacht und als JSON an
|
||||||
|
// `cut_section_json` gereicht.
|
||||||
|
// 3. Das zurückgegebene `SectionOutput` (Cut-Polygone + sichtbare/verdeckte
|
||||||
|
// Kanten, alles in (u, v)-Metern) wird in `generateSectionPlan`
|
||||||
|
// (generatePlan.ts) zu Plan-Primitiven übersetzt.
|
||||||
|
//
|
||||||
|
// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene,
|
||||||
|
// v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs.
|
||||||
|
|
||||||
|
import type { DrawingLevel, Project } from "../model/types";
|
||||||
|
import { projectToModel3d } from "./toWalls3d";
|
||||||
|
import { loadEngine3d } from "../engine/engine3d";
|
||||||
|
|
||||||
|
/** Bauteil-Referenz eines Cut-Polygons/einer Kante (Art + Eingabe-Index). */
|
||||||
|
export interface SectionComponentRef {
|
||||||
|
kind: "wall" | "slab";
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein geschnittenes Bauteil-Rechteck in (u, v)-Metern (Ring, implizit geschlossen). */
|
||||||
|
export interface SectionCutPolygon {
|
||||||
|
component: SectionComponentRef;
|
||||||
|
/** Albedo-Farbe des Bauteils (RGB 0..1). */
|
||||||
|
color: [number, number, number];
|
||||||
|
/** Ring-Ecken (u, v). */
|
||||||
|
pts: Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein projiziertes Liniensegment in (u, v)-Metern. */
|
||||||
|
export interface SectionEdge {
|
||||||
|
component: SectionComponentRef;
|
||||||
|
a: [number, number];
|
||||||
|
b: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollständige Ausgabe eines Schnitt-/Ansichts-Laufs (JSON von `cut_section_json`). */
|
||||||
|
export interface SectionOutput {
|
||||||
|
cutPolygons: SectionCutPolygon[];
|
||||||
|
visibleEdges: SectionEdge[];
|
||||||
|
hiddenEdges: SectionEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal-Typ des WASM-Exports (das echte `.d.ts` entsteht erst beim wasm-Build). */
|
||||||
|
interface Engine3dSection {
|
||||||
|
cut_section_json(
|
||||||
|
modelJson: string,
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
pz: number,
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
nz: number,
|
||||||
|
): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Schnittebene in world-Koordinaten (Y-up): Punkt auf der Ebene +
|
||||||
|
* Normale (= Blickrichtung, zeigt vom Betrachter ins Modell).
|
||||||
|
*/
|
||||||
|
export interface SectionPlaneSpec {
|
||||||
|
point: [number, number, number];
|
||||||
|
normal: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet aus einer Schnitt-/Ansichtsebene die world-Schnittebene ab. Die
|
||||||
|
* Grundriss-Schnittlinie `linePoints` [p0, p1] (Modell-Meter) liegt in der
|
||||||
|
* XZ-Ebene (world.x = model.x, world.z = model.y); die Ebenennormale steht
|
||||||
|
* senkrecht auf der Linie in der Horizontalen und wird über `directionSign`
|
||||||
|
* (Voreinstellung +1) in die gewünschte Blickrichtung gedreht.
|
||||||
|
*
|
||||||
|
* Gibt `null` zurück, wenn die Ebene (noch) keine Schnittlinie trägt (z. B. eine
|
||||||
|
* frisch angelegte Ansicht ohne gesetzte Linie) — der Aufrufer zeigt dann einen
|
||||||
|
* Hinweis statt eines leeren Plans.
|
||||||
|
*/
|
||||||
|
export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | null {
|
||||||
|
const line = level.linePoints;
|
||||||
|
if (!line) return null;
|
||||||
|
const [p0, p1] = line;
|
||||||
|
const dx = p1.x - p0.x;
|
||||||
|
const dy = p1.y - p0.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 1e-9) return null;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
// Links-Normale der Linie im Grundriss (n = (-u.y, u.x)), per directionSign
|
||||||
|
// in die Blickrichtung gedreht. Modell (nx, ny) → world (nx, 0, ny).
|
||||||
|
const sign = level.directionSign ?? 1;
|
||||||
|
const nx = -uy * sign;
|
||||||
|
const ny = ux * sign;
|
||||||
|
return {
|
||||||
|
point: [p0.x, 0, p0.y],
|
||||||
|
normal: [nx, 0, ny],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet den Schnitt/die Ansicht der Ebene `level` gegen das Modell `project`
|
||||||
|
* über den WASM-Extraktor. Asynchron (das render3d-WASM-Modul wird lazy geladen).
|
||||||
|
*
|
||||||
|
* Gibt `null` zurück, wenn die Ebene keine gültige Schnittlinie hat. Wirft, wenn
|
||||||
|
* das WASM-Modul den Export (noch) nicht bereitstellt — der Aufrufer fängt das ab
|
||||||
|
* und blendet einen Fallback ein (pkg3d muss dann über `npm run build:engine3d`
|
||||||
|
* neu gebaut werden, damit `cut_section_json` verfügbar ist).
|
||||||
|
*/
|
||||||
|
export async function computeSection(
|
||||||
|
project: Project,
|
||||||
|
level: DrawingLevel,
|
||||||
|
): Promise<SectionOutput | null> {
|
||||||
|
const plane = sectionPlaneFromLevel(level);
|
||||||
|
if (!plane) return null;
|
||||||
|
const mod = (await loadEngine3d()) as Engine3dSection;
|
||||||
|
if (typeof mod.cut_section_json !== "function") {
|
||||||
|
throw new Error(
|
||||||
|
"render3d-WASM ohne cut_section_json — pkg3d neu bauen (npm run build:engine3d)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const modelJson = JSON.stringify(projectToModel3d(project));
|
||||||
|
const json = mod.cut_section_json(
|
||||||
|
modelJson,
|
||||||
|
plane.point[0],
|
||||||
|
plane.point[1],
|
||||||
|
plane.point[2],
|
||||||
|
plane.normal[0],
|
||||||
|
plane.normal[1],
|
||||||
|
plane.normal[2],
|
||||||
|
);
|
||||||
|
return JSON.parse(json) as SectionOutput;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Project } from "../model/types";
|
import type { Project } from "../model/types";
|
||||||
import { projectToModel3d } from "../plan/toWalls3d";
|
import { projectToModel3d } from "../plan/toWalls3d";
|
||||||
|
import { loadEngine3d } from "../engine/engine3d";
|
||||||
|
|
||||||
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
|
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
|
||||||
export interface Camera3d {
|
export interface Camera3d {
|
||||||
@@ -54,23 +55,6 @@ interface WebModelRendererLike {
|
|||||||
render(): void;
|
render(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* WASM-Modul EINMAL laden + initialisieren (memoisiert). `mod.default()` darf
|
|
||||||
* nur einmal laufen — ein zweiter Init (React-StrictMode-Doppelmount) verwirft
|
|
||||||
* die Closures des ersten („closure invoked recursively or after being dropped").
|
|
||||||
*/
|
|
||||||
let enginePromise: Promise<any> | null = null;
|
|
||||||
function loadEngine(): Promise<any> {
|
|
||||||
if (!enginePromise) {
|
|
||||||
enginePromise = (async () => {
|
|
||||||
const mod: any = await import("../engine/pkg3d/render3d.js");
|
|
||||||
await mod.default(); // lädt render3d_bg.wasm
|
|
||||||
return mod;
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
return enginePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
|
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
|
||||||
* WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben
|
* WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben
|
||||||
@@ -82,7 +66,7 @@ function getRenderer(canvas: HTMLCanvasElement): Promise<WebModelRendererLike> {
|
|||||||
let p = rendererCache.get(canvas);
|
let p = rendererCache.get(canvas);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
p = (async () => {
|
p = (async () => {
|
||||||
const mod = await loadEngine();
|
const mod = await loadEngine3d();
|
||||||
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
|
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user