From 4beae72eea15a8e871fbcbc1991940a7966edcb8 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 20:59:21 +0200 Subject: [PATCH] =?UTF-8?q?Fenster/T=C3=BCr=203D:=20tiefer,=20Detailgrad?= =?UTF-8?q?=20im=203D,=20kein=20Fenster=E2=86=92T=C3=BCr-Umschalten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer-Feedback: - Fenster wirkten im 3D flach: die Rahmen-Einbautiefe wurde fälschlich aus frameThickness (Profil-Breite, ~6 cm) statt aus der Wanddicke bestimmt. Jetzt füllt der Rahmen die VOLLE Wanddicke (tiefe Laibung), die Scheibe ist eine dünne Tafel mittig darin → plastische Fenster mit sichtbarer Laibung. frameDepth übersteuert weiterhin. - Detailgrad grob/mittel/fein wirkt jetzt auch im 3D: grob = nur Loch + Scheibe, mittel = Rahmen (+Oberlicht-Kämpfer), fein = zusätzlich Flügel-Mittelpfosten/Kämpfer-Sprossen. Durchgereicht App → Viewport3D → Wasm3DViewport → useWasm3dRenderer → projectToModel3d({detail}). - Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige): die Gattung wird beim Platzieren festgelegt, nicht nachträglich getauscht. +1 Test (Detailgrad-Gating grob/mittel/fein). tsc + vitest grün. --- src/App.tsx | 1 + src/panels/ObjectInfoPanel.tsx | 23 +++-------- src/plan/toWalls3d.test.ts | 17 ++++++++ src/plan/toWalls3d.ts | 69 +++++++++++++++++++++---------- src/viewport/Viewport3D.tsx | 5 ++- src/viewport/Wasm3DViewport.tsx | 13 ++++-- src/viewport/useWasm3dRenderer.ts | 8 ++-- 7 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 566d19d..daa22cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6124,6 +6124,7 @@ function Content({ categoryDisplay={categoryDisplay} displayKey={displayKey} renderMode={renderMode} + detail={detail} view3d={view3d} fov={fov} gridElevation={level.baseElevation ?? 0} diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index 049ce44..8b96e15 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -606,25 +606,14 @@ export function OpeningSection({ - {/* Art-Segment Fenster/Tür. */} + {/* Art (Fenster/Tür) — read-only: die Gattung wird beim Platzieren mit dem + jeweiligen Werkzeug festgelegt und NICHT nachträglich umgeschaltet + (ein Fenster in eine Tür zu verwandeln ergibt keinen sinnvollen Bauteil). */}
{t("objinfo.opening.kind")} -
- - -
+ + {t(isDoor ? "objinfo.opening.kind.door" : "objinfo.opening.kind.window")} +
{/* Wirts-Wand (read-only Anzeige des Geschosses/Wand-Bezugs). */} diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 7f6f409..52ce2ec 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -332,6 +332,23 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( } }); + it("Detailgrad steuert Rahmen/Sprossen im 3D (grob=0, mittel=Rahmen, fein=+Mittelpfosten)", () => { + // O1 mit erzwungenem wingCount 2 -> im FEIN-Fall ein Flügel-Mittelpfosten extra. + const o1 = (sampleProject.openings ?? []).find((o) => o.id === "O1"); + if (!o1) throw new Error("O1 fehlt im sampleProject"); + const p: Project = { + ...sampleProject, + openings: [{ ...o1, wingCount: 2 }], + doors: [], + context: [], + }; + const frameCount = (detail: "grob" | "mittel" | "fein"): number => + projectToModel3d(p, { detail }).meshes.filter(isFrame).length; + expect(frameCount("grob")).toBe(0); // keine Rahmen/Sprossen + expect(frameCount("mittel")).toBe(4); // 2 Pfosten + Sturz + Schwelle, KEIN Mittelpfosten + expect(frameCount("fein")).toBe(5); // + 1 Flügel-Mittelpfosten (wingCount 2) + }); + it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => { const p: Project = { ...sampleProject, diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 39f43c9..9cde0fa 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -49,6 +49,7 @@ import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairV import { columnFootprint } from "../geometry/column"; import { pointInOutline } from "../geometry/ceiling"; import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening"; +import type { DetailLevel } from "../ui/TopBar"; import { stairGeometry, stairBBox } from "../geometry/stair"; import { extrudePolygon, extrudeCircle } from "../engine/truckSolid"; import { computeJoins } from "../model/joins"; @@ -1729,11 +1730,13 @@ function clampRange(v: number, lo: number, hi: number): number { * (übersprungen, nächster Fallback greift). */ function resolveAcrossWallDepth( - frameThickness: number | undefined, frameDepth: number | undefined, wallThickness: number, ): number { - if (frameThickness !== undefined && frameThickness > EPS) return frameThickness; + // Rahmen-Tiefe quer zur Wand: explizite `frameDepth` gewinnt, sonst füllt der + // Rahmen die VOLLE Wanddicke (tiefe Laibung/Reveal). Das `frameThickness` ist + // die Profil-Breite des Rahmenstabs (2D/Ansicht), NICHT die Einbautiefe — es + // hier zu verwenden liess Fenster im 3D flach wirken (nur wenige cm tief). if (frameDepth !== undefined && frameDepth > EPS) return frameDepth; return wallThickness; } @@ -1798,7 +1801,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening if (op.kind === "door") { const dt = getDoorType(project, op); if (!dt) return null; - const acrossWallDepth = resolveAcrossWallDepth(dt.frameThickness, dt.frameDepth, wallThickness); + const acrossWallDepth = resolveAcrossWallDepth(dt.frameDepth, wallThickness); const { nMin, nMax } = resolveFrameNormalRange( halfThickness, acrossWallDepth, @@ -1819,7 +1822,7 @@ function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): Opening } const wt = getWindowType(project, op); if (!wt) return null; - const acrossWallDepth = resolveAcrossWallDepth(wt.frameThickness, wt.frameDepth, wallThickness); + const acrossWallDepth = resolveAcrossWallDepth(wt.frameDepth, wallThickness); const { nMin, nMax } = resolveFrameNormalRange( halfThickness, acrossWallDepth, @@ -1854,6 +1857,7 @@ function frameMeshesForOpening( iv: { from: number; to: number }, vert: { zBottom: number; zTop: number }, params: OpeningFrameParams, + fine = true, ): RMesh[] { const out: RMesh[] = []; const fw = params.frameWidth; @@ -1880,20 +1884,24 @@ function frameMeshesForOpening( push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel sashTop = splitZ - fw / 2; } - // Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich. - if (params.wingCount > 1 && sashTo - sashFrom > EPS) { - const cell = (sashTo - sashFrom) / params.wingCount; - for (let i = 1; i < params.wingCount; i++) { - const center = sashFrom + i * cell; - push(center - fw / 2, center + fw / 2, sashBottom, sashTop); + // Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) nur bei FEINEM Detailgrad — + // bei „mittel" bleibt es beim reinen Rahmen (+ Oberlicht-Kämpfer oben). + if (fine) { + // Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich. + if (params.wingCount > 1 && sashTo - sashFrom > EPS) { + const cell = (sashTo - sashFrom) / params.wingCount; + for (let i = 1; i < params.wingCount; i++) { + const center = sashFrom + i * cell; + push(center - fw / 2, center + fw / 2, sashBottom, sashTop); + } } - } - // Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich. - if (params.mullionRows > 1 && sashTop - sashBottom > EPS) { - const cell = (sashTop - sashBottom) / params.mullionRows; - for (let i = 1; i < params.mullionRows; i++) { - const center = sashBottom + i * cell; - push(sashFrom, sashTo, center - fw / 2, center + fw / 2); + // Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich. + if (params.mullionRows > 1 && sashTop - sashBottom > EPS) { + const cell = (sashTop - sashBottom) / params.mullionRows; + for (let i = 1; i < params.mullionRows; i++) { + const center = sashBottom + i * cell; + push(sashFrom, sashTo, center - fw / 2, center + fw / 2); + } } } return out; @@ -1919,8 +1927,15 @@ function glassPanesForOpening( const sashTo = iv.to - fw; const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); const sashTopFull = vert.zTop - fw; + // Die Scheibe ist eine DÜNNE Tafel mittig in der (nun tiefen) Rahmen-Laibung — + // nicht der volle Rahmen-Querschnitt, sonst wäre das „Glas" ein dicker Block + // und die Laibung/Reveal-Tiefe unsichtbar. So liest sich das Fenster plastisch: + // Rahmen zieht in die Wand, die Scheibe sitzt zurückgesetzt in der Mitte. + const glassMid = (params.nMin + params.nMax) / 2; + const gN0 = glassMid - GLASS_HALF_THICK; + const gN1 = glassMid + GLASS_HALF_THICK; const push = (zBottom: number, zTop: number): void => { - const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, params.nMin, params.nMax, GLASS_RGB); + const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, gN0, gN1, GLASS_RGB); if (mesh) out.push(mesh); }; if (params.transomHeight > EPS) { @@ -2002,7 +2017,10 @@ function emitOpeningGlass(project: Project): RMesh[] { * fehlt/verwaist) bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der * Typ-Vertiefung (Rückwärtskompatibilität). */ -function emitOpeningFrames(project: Project): RMesh[] { +function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] { + // Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank). + if (detail === "grob") return []; + const fine = detail === "fein"; const out: RMesh[] = []; for (const wall of project.walls) { for (const op of openingsOfWall(project, wall.id)) { @@ -2012,7 +2030,7 @@ function emitOpeningFrames(project: Project): RMesh[] { if (vert.zTop - vert.zBottom <= EPS) continue; const params = resolveOpeningFrame(project, wall, op); if (!params) continue; - out.push(...frameMeshesForOpening(wall, iv, vert, params)); + out.push(...frameMeshesForOpening(wall, iv, vert, params, fine)); } } return out; @@ -2070,11 +2088,20 @@ function computeJoinsByFloor(project: Project): Map { */ export interface Model3dOptions { layeredWalls?: boolean; + /** + * Detailgrad der Öffnungs-Darstellung im 3D (wie im 2D-Grundriss): + * • "grob" — keine Rahmen/Sprossen (nur Loch + Scheibe). + * • "mittel"— Rahmen (Pfosten/Sturz/Schwelle) + ggf. Kämpfer, keine Sprossen. + * • "fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen). + * Fehlt es, gilt "fein" (= bisheriges Verhalten, volle Details). + */ + detail?: DetailLevel; } /** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): RModel3d { const layered = opts.layeredWalls ?? true; + const detail = opts.detail ?? "fein"; return { walls: projectToWalls3d(project, layered), // Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe @@ -2089,7 +2116,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R meshes: [ ...emitMeshes(project), ...emitOpeningGlass(project), - ...emitOpeningFrames(project), + ...emitOpeningFrames(project, detail), ...emitStairMeshes(project), ], }; diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 4e08a60..ea3b8ab 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -47,7 +47,7 @@ import { import { MaterialRuntime } from "../materials/runtime"; import { Wasm3DViewport } from "./Wasm3DViewport"; import type { Pick3dHit } from "./Wasm3DViewport"; -import type { RenderMode, View3d } from "../ui/TopBar"; +import type { DetailLevel, RenderMode, View3d } from "../ui/TopBar"; import type { ToolDraft } from "../tools/types"; import { applyAngleConstraint } from "../tools/snapping"; import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips"; @@ -143,6 +143,7 @@ export function Viewport3D( project={props.project} view3d={props.view3d} renderMode={props.renderMode} + detail={props.detail} gridVisible={gridVisible} groundElevation={props.gridElevation} onToggleGrid={onToggleGrid} @@ -210,6 +211,8 @@ function ThreeViewport3D({ * schwarze Kantenlinien, tiefen-getestet → Hidden-Line-Look). Default shaded. */ renderMode?: RenderMode; + /** Detailgrad (grob/mittel/fein) — reicht die Öffnungs-Details ins wgpu-Modell. */ + detail?: DetailLevel; /** * Render-Entscheidung je Geschoss-ID (Darstellungsmodus „Zeichnungsebenen"). * Ohne Angabe werden alle sichtbaren Geschosse normal gezeigt. diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index e939aa7..e99dbe1 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -46,7 +46,7 @@ import { useEffect, useRef, useState } from "react"; import type { Project, Wall, Drawing2D, Ceiling, Vec2 } from "../model/types"; import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall"; import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d"; -import type { RenderMode, View3d } from "../ui/TopBar"; +import type { DetailLevel, RenderMode, View3d } from "../ui/TopBar"; import { t } from "../i18n"; import { useWasm3dRenderer } from "./useWasm3dRenderer"; import type { Camera3d } from "./useWasm3dRenderer"; @@ -288,6 +288,7 @@ export function Wasm3DViewport({ project, view3d = "perspective", renderMode = "shaded", + detail = "fein", gridVisible = true, groundElevation = 0, onToggleGrid, @@ -308,6 +309,8 @@ export function Wasm3DViewport({ /** Darstellungsart (Oberleiste) — alle fünf Werte wirken hier, s. * Datei-Kommentar. */ renderMode?: RenderMode; + /** Detailgrad (grob/mittel/fein) — steuert die Öffnungs-Rahmen/Sprossen im 3D. */ + detail?: DetailLevel; /** Boden-Referenzraster sichtbar? Umgeschaltet über den Overlay-Button unten * rechts im Viewport (`onToggleGrid`) — Single Source of Truth ist der * Aufrufer (App/viewSlice `grid3dVisible`), diese Komponente spiegelt nur. */ @@ -382,6 +385,8 @@ export function Wasm3DViewport({ onEditEndRef.current = onEditEnd; const groundElevationRef = useRef(groundElevation); groundElevationRef.current = groundElevation; + const detailRef = useRef(detail); + detailRef.current = detail; const { render, updateModel, @@ -414,9 +419,9 @@ export function Wasm3DViewport({ // Snapshot) — nur das `view3d`-Effekt unten springt aktiv auf ein Praeset. useEffect(() => { if (!ready) return; - updateModel(project); + updateModel(project, detail); redrawRef.current(); - }, [ready, project, updateModel]); + }, [ready, project, detail, updateModel]); // truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids): die WASM-Extrusion // läuft asynchron und ist beim ersten `updateModel` oben typischerweise noch @@ -425,7 +430,7 @@ export function Wasm3DViewport({ useEffect(() => { if (!ready) return; const onMeshReady = () => { - updateModel(projectRef.current); + updateModel(projectRef.current, detailRef.current); redrawRef.current(); }; window.addEventListener(TRUCK_MESH_READY_EVENT, onMeshReady); diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index d6def9e..08e8d30 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -19,6 +19,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Project } from "../model/types"; import { projectToModel3d } from "../plan/toWalls3d"; +import type { DetailLevel } from "../ui/TopBar"; import { loadEngine3d } from "../engine/engine3d"; import { decodeMaterialTextures } from "./materialTextures"; @@ -166,12 +167,13 @@ export function useWasm3dRenderer( // geändert haben (updateModel läuft bei JEDER Projektänderung). const materialSigRef = useRef(null); - /** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). */ - const updateModel = useCallback((project: Project) => { + /** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail` + * steuert den Öffnungs-Detailgrad (grob/mittel/fein) im 3D wie im Grundriss. */ + const updateModel = useCallback((project: Project, detail?: DetailLevel) => { const r = rendererRef.current; if (!r || !project) return; try { - r.set_model(JSON.stringify(projectToModel3d(project))); + r.set_model(JSON.stringify(projectToModel3d(project, { detail }))); } catch (e) { console.warn("render3d set_model fehlgeschlagen:", e); }