5180 fichiers 38.5 Mo 0 téléchargements 290 vues mis à jour il y a 6 heures ui
Floorplan3DView.tsx 15.6 Ko Télécharger le fichier
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { FaExpand, FaMinus, FaPlus, FaSyncAlt } from 'react-icons/fa';
import { PointerProjection } from '../hooks/usePointerToTile';
import { useTool } from '../hooks/useTool';
import { FloorplanScene } from '../scene3d/FloorplanScene';
import { buildHeightmapInstances, cameraEye, clampCamera, fitCamera, OrbitCamera, panCamera, roomBounds, roomCenter, visibleWallSides, WallSides } from '../scene3d/heightmap';
import { mat4LookAt, mat4Multiply, mat4Perspective } from '../scene3d/mat4';
import { cameraRay, pickTile, TileHit } from '../scene3d/picking';
import { FloorplanAction, FloorplanState } from '../state/types';
import { BaseButton } from '../../../common/BaseButton';
import { Hint } from '../../../common/Tooltip';
import { NativeTextTree } from '../../../common/native-text/NativeTextTree';

type Props = {
    state: FloorplanState;
    dispatch: Dispatch<FloorplanAction>;
    /** Hand tool: left-drag orbits instead of editing. */
    panMode?: boolean;
};

type Status = 'loading' | 'ready' | 'unavailable';

const FOV_Y = Math.PI / 4;
const NEAR = 0.1;
const FAR = 600;
const ROTATE_SPEED = 0.008;
const ZOOM_STEP = 1.2;
/** Turntable speed: one full turn every 12 seconds. */
const AUTO_ROTATE_RATE = (Math.PI * 2) / 12;

type Drag = { mode: 'rotate' | 'pan' | 'tool'; lastX: number; lastY: number };

const sameTile = (a: TileHit | null, b: TileHit | null): boolean => (a === b) || (!!a && !!b && a.row === b.row && a.col === b.col);

/**
 * 3D view of the floorplan: the same state the SVG editor edits, drawn as
 * extruded tile columns by PixiJS, and editable with the same tools. Click or
 * drag paints with the active tool (or orbits in hand mode); right-, middle-
 * or alt-drag orbits; shift-drag pans; the wheel zooms; double-click
 * re-frames the room.
 */
export const Floorplan3DView: FC<Props> = ({ state, dispatch, panMode = false }) => {
    const hostRef = useRef<HTMLDivElement>(null);
    const sceneRef = useRef<FloorplanScene | null>(null);
    const cameraRef = useRef<OrbitCamera | null>(null);
    const sizeRef = useRef<{ width: number; height: number }>({ width: 1, height: 1 });
    const frameRef = useRef<number>(0);
    const dragRef = useRef<Drag | null>(null);
    const tilesRef = useRef(state.tiles);
    const [status, setStatus] = useState<Status>('loading');
    const [zoomLabel, setZoomLabel] = useState('');
    const [hover, setHover] = useState<TileHit | null>(null);
    const [showWalls, setShowWalls] = useState(true);
    const [textured, setTextured] = useState(true);
    const [wallSides, setWallSides] = useState<WallSides>({ north: true, west: true });
    const [autoRotate, setAutoRotate] = useState(false);

    const { tiles, door, selection, thickness, wallHeight } = state;

    useLayoutEffect(() => {
        tilesRef.current = tiles;
    }, [tiles]);
    const bounds = useMemo(() => roomBounds({ tiles }), [tiles]);
    const instances = useMemo(
        () => buildHeightmapInstances({ tiles, door, selection, thickness, wallHeight }, { hover, walls: showWalls, wallSides, doorMarker: true }),
        [tiles, door, selection, thickness, wallHeight, hover, showWalls, wallSides]
    );

    /** Screen position to tile, through the current camera; the tools use it like the SVG projection. */
    const projection = useMemo<PointerProjection>(
        () => ({
            fromClient: (clientX: number, clientY: number) => {
                const host = hostRef.current;
                const camera = cameraRef.current;

                if (!host || !camera) return null;

                const rect = host.getBoundingClientRect();

                if (rect.width < 1 || rect.height < 1) return null;

                const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
                const ndcY = 1 - ((clientY - rect.top) / rect.height) * 2;

                return pickTile(cameraRay(camera, rect.width / rect.height, FOV_Y, ndcX, ndcY), tilesRef.current);
            }
        }),
        []
    );

    const tool = useTool(state, dispatch, projection);

    const requestRender = useCallback(() => {
        if (frameRef.current) return;

        frameRef.current = window.requestAnimationFrame(() => {
            frameRef.current = 0;

            const scene = sceneRef.current;
            const camera = cameraRef.current;

            if (!scene || !camera) return;

            const { width, height } = sizeRef.current;
            const view = mat4LookAt(cameraEye(camera), camera.target);
            const projection = mat4Perspective(FOV_Y, width / Math.max(1, height), NEAR, FAR);

            scene.setCamera(mat4Multiply(projection, view));
            scene.render();
        });
    }, []);

    const setCamera = useCallback(
        (next: OrbitCamera) => {
            const camera = clampCamera(next);

            cameraRef.current = camera;
            setZoomLabel(`${Math.round(camera.distance)}`);

            const sides = visibleWallSides(cameraEye(camera), roomCenter(bounds));
            setWallSides((current) => (current.north === sides.north && current.west === sides.west ? current : sides));

            requestRender();
        },
        [bounds, requestRender]
    );

    const resetView = useCallback(() => {
        const { width, height } = sizeRef.current;
        setCamera(fitCamera(bounds, width / Math.max(1, height)));
    }, [bounds, setCamera]);

    // Create the PixiJS scene once the canvas is on screen.
    useEffect(() => {
        const host = hostRef.current;

        if (!host) return;

        let disposed = false;
        let scene: FloorplanScene | null = null;

        const rect = host.getBoundingClientRect();
        sizeRef.current = { width: Math.max(1, rect.width), height: Math.max(1, rect.height) };

        FloorplanScene.create(host, sizeRef.current.width, sizeRef.current.height)
            .then((created) => {
                if (disposed) {
                    created.destroy();
                    return;
                }

                scene = created;
                sceneRef.current = created;
                setStatus('ready');
            })
            .catch(() => {
                if (!disposed) setStatus('unavailable');
            });

        const observer = new ResizeObserver((entries) => {
            const entry = entries[0];

            if (!entry) return;

            const width = Math.floor(entry.contentRect.width);
            const height = Math.floor(entry.contentRect.height);

            if (width < 1 || height < 1) return;
            if (sizeRef.current.width === width && sizeRef.current.height === height) return;

            sizeRef.current = { width, height };
            sceneRef.current?.resize(width, height);
            requestRender();
        });

        observer.observe(host);

        return () => {
            disposed = true;
            observer.disconnect();

            if (frameRef.current) {
                window.cancelAnimationFrame(frameRef.current);
                frameRef.current = 0;
            }

            sceneRef.current = null;
            scene?.destroy();
        };
    }, [requestRender]);

    useEffect(() => {
        if (status !== 'ready') return;

        sceneRef.current?.setTextured(textured);
        requestRender();
    }, [status, textured, requestRender]);

    // Frame the room the first time the scene is ready, then follow every edit.
    useEffect(() => {
        if (status !== 'ready') return;

        sceneRef.current?.update(instances);

        if (!cameraRef.current) resetView();
        else requestRender();
    }, [status, instances, resetView, requestRender]);

    // 360° turntable: keep orbiting while enabled; the wall culling follows the camera.
    useEffect(() => {
        if (!autoRotate || status !== 'ready') return;

        let frame = 0;
        let last = performance.now();

        const step = (now: number) => {
            const camera = cameraRef.current;
            const dt = Math.min(0.1, (now - last) / 1000);

            last = now;

            if (camera) setCamera({ ...camera, yaw: camera.yaw + AUTO_ROTATE_RATE * dt });

            frame = window.requestAnimationFrame(step);
        };

        frame = window.requestAnimationFrame(step);

        return () => window.cancelAnimationFrame(frame);
    }, [autoRotate, status, setCamera]);

    const updateHover = (event: ReactPointerEvent<HTMLDivElement>) => {
        const next = projection.fromClient(event.clientX, event.clientY);

        setHover((current) => (sameTile(current, next) ? current : next));
        sceneRef.current?.setHover(next);
        requestRender();
    };

    const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
        if (status !== 'ready') return;

        const orbit = event.button === 1 || event.button === 2 || event.altKey || (event.button === 0 && panMode);
        const pan = event.shiftKey;

        if (pan || orbit) {
            setAutoRotate(false);
            dragRef.current = { mode: pan ? 'pan' : 'rotate', lastX: event.clientX, lastY: event.clientY };
            event.currentTarget.setPointerCapture(event.pointerId);
            event.preventDefault();
            return;
        }

        if (event.button !== 0) return;

        dragRef.current = { mode: 'tool', lastX: event.clientX, lastY: event.clientY };
        tool.onPointerDown(event);
    };

    const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
        const drag = dragRef.current;
        const camera = cameraRef.current;

        if (!drag) {
            if (status === 'ready') updateHover(event);
            return;
        }

        if (drag.mode === 'tool') {
            updateHover(event);
            tool.onPointerMove(event);
            return;
        }

        if (!camera) return;

        const dx = event.clientX - drag.lastX;
        const dy = event.clientY - drag.lastY;

        drag.lastX = event.clientX;
        drag.lastY = event.clientY;

        if (drag.mode === 'rotate') setCamera({ ...camera, yaw: camera.yaw - dx * ROTATE_SPEED, pitch: camera.pitch + dy * ROTATE_SPEED });
        else setCamera(panCamera(camera, dx, dy));
    };

    const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
        const drag = dragRef.current;

        if (!drag) return;

        dragRef.current = null;

        if (drag.mode === 'tool') {
            tool.onPointerUp(event);
            return;
        }

        if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
    };

    const onPointerLeave = () => {
        if (dragRef.current) return;

        setHover(null);
        sceneRef.current?.setHover(null);
        requestRender();
    };

    const zoomBy = (factor: number) => {
        const camera = cameraRef.current;

        if (!camera) return;

        setCamera({ ...camera, distance: camera.distance * factor });
    };

    const onWheel = (event: WheelEvent<HTMLDivElement>) => {
        if (status !== 'ready') return;

        zoomBy(event.deltaY > 0 ? ZOOM_STEP : 1 / ZOOM_STEP);
    };

    return (
        <NativeTextTree>
            <div
                ref={hostRef}
                data-testid="floorplan-3d"
                className={`fp-stage select-none touch-none ${panMode ? 'cursor-grab active:cursor-grabbing' : 'cursor-crosshair'}`}
                onPointerDown={onPointerDown}
                onPointerMove={onPointerMove}
                onPointerUp={endDrag}
                onPointerCancel={endDrag}
                onPointerLeave={onPointerLeave}
                onWheel={onWheel}
                onDoubleClick={resetView}
                onContextMenu={(event) => event.preventDefault()}
            >
                {status === 'loading' && (
                    <div className="absolute z-10 inset-0 flex items-center justify-center text-xs text-zinc-400" data-testid="floorplan-3d-loading">
                        Loading 3D preview...
                    </div>
                )}
                {status === 'unavailable' && (
                    <div className="absolute z-10 inset-0 flex items-center justify-center px-4 text-center text-xs text-zinc-300" data-testid="floorplan-3d-unavailable">
                        The 3D preview needs WebGL, which is not available here.
                    </div>
                )}
                <div className="fp-stage-buttons" onPointerDown={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}>
                    <Hint content={`Fit room to view (double-click)${zoomLabel ? ` · distance ${zoomLabel}` : ''}`}><BaseButton aria-label={`Fit room to view (double-click)${zoomLabel ? ` · distance ${zoomLabel}` : ''}`} type="button" data-testid="view3d-reset" className="fp-round-btn is-small" disabled={status !== 'ready'} onClick={resetView}>
                        <FaExpand size={10} />
                    </BaseButton></Hint>
                    <Hint content="Zoom in (wheel)"><BaseButton aria-label="Zoom in (wheel)" type="button" data-testid="view3d-zoom-in" className="fp-round-btn" disabled={status !== 'ready'} onClick={() => zoomBy(1 / ZOOM_STEP)}>
                        <FaPlus size={13} />
                    </BaseButton></Hint>
                    <Hint content="Zoom out (wheel)"><BaseButton aria-label="Zoom out (wheel)" type="button" data-testid="view3d-zoom-out" className="fp-round-btn" disabled={status !== 'ready'} onClick={() => zoomBy(ZOOM_STEP)}>
                        <FaMinus size={13} />
                    </BaseButton></Hint>
                </div>
                <div className="fp-stage-toggles" onPointerDown={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}>
                    <Hint content="Show the room walls"><BaseButton aria-label="Show the room walls" type="button" data-testid="view3d-walls" data-active={showWalls ? 'true' : 'false'} className={`fp-pill ${showWalls ? 'is-on' : ''}`} onClick={() => setShowWalls((value) => !value)}>
                        Walls
                    </BaseButton></Hint>
                    <Hint content="Draw floor and wall patterns instead of the height colours"><BaseButton aria-label="Draw floor and wall patterns instead of the height colours"
                        type="button"
                        data-testid="view3d-textures"
                        data-active={textured ? 'true' : 'false'}
                        className={`fp-pill ${textured ? 'is-on' : ''}`}
                        onClick={() => setTextured((value) => !value)}
                    >
                        Textures
                    </BaseButton></Hint>
                    <Hint content="Turn the room around 360°"><BaseButton aria-label="Turn the room around 360°"
                        type="button"
                        data-testid="view3d-rotate"
                        data-active={autoRotate ? 'true' : 'false'}
                        className={`fp-pill inline-flex items-center gap-1 ${autoRotate ? 'is-on' : ''}`}
                        disabled={status !== 'ready'}
                        onClick={() => setAutoRotate((value) => !value)}
                    >
                        <FaSyncAlt size={10} className={autoRotate ? 'animate-spin' : ''} style={autoRotate ? { animationDuration: '3s' } : undefined} />
                        360°
                    </BaseButton></Hint>
                </div>
                <div className="fp-stage-hint">
                    {panMode ? 'Drag to rotate · Shift-drag to pan · Wheel to zoom' : 'Click or drag to edit · Right-drag to rotate · Shift-drag to pan · Wheel to zoom'}
                </div>
            </div>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser