@@ -4,7 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from
44import type { PointerEvent as ReactPointerEvent , DragEvent as ReactDragEvent , ReactNode } from 'react' ;
55import {
66 MousePointer2 , Move , RotateCw , Scale3d , Grid3x3 , Frame ,
7- Camera , Check , ChevronDown , Loader2 , TriangleAlert , Lightbulb , Sparkles , Globe , Crosshair , Smartphone , Monitor , Magnet , Axis3d , Hexagon , type LucideIcon ,
7+ Camera , Check , ChevronDown , Loader2 , TriangleAlert , Lightbulb , Sparkles , Globe , Crosshair , Smartphone , Monitor , Magnet , Axis3d , Hexagon , MapPin , type LucideIcon ,
88 AlignStartVertical , AlignCenterVertical , AlignEndVertical ,
99 AlignStartHorizontal , AlignCenterHorizontal , AlignEndHorizontal ,
1010 AlignHorizontalDistributeCenter , AlignVerticalDistributeCenter ,
@@ -19,7 +19,7 @@ import { useEditorMode } from '@/store/editorModeStore';
1919import { RESOLUTION_PRESETS , RESOLUTION_PRESET_BY_ID , DESIGN_RESOLUTION_PRESETS , deviceDims } from '@/mode/resolutionPresets' ;
2020import { buildStampGhost } from '@/tools/tileStampGhost' ;
2121import { alignSelection , distributeSelection } from '@/tools/alignTools' ;
22- import { TilemapAPI , tileIdOf , isNonOrthogonal , UINode , DimensionUnit , computeEffectiveOrthoSize , type TileCollisionPiece , type TilesetModel } from 'esengine' ;
22+ import { TilemapAPI , tileIdOf , isNonOrthogonal , isCollisionPaletteRef , buildCollisionPaletteModel , UINode , DimensionUnit , computeEffectiveOrthoSize , type TileCollisionPiece , type TilesetModel } from 'esengine' ;
2323import { commands } from '@/commands' ;
2424import { MOD_LABEL } from '@/commands/keybinding' ;
2525import { EngineHost } from '@/engine/EngineHost' ;
@@ -865,6 +865,14 @@ export function Viewport() {
865865 ( ) => ( engine . status === 'ready' ? ViewportController . light2DIds ( ) : [ ] ) ,
866866 [ structRev , engine . status ] ,
867867 ) ;
868+ // Marker (point-object) entities render nothing — draw each as an always-on pin at its
869+ // position (click to select), so spawn points / waypoints / triggers are visible without
870+ // being selected. Same per-frame rAF + structural id set as the camera/light gizmos.
871+ const markerRefs = useRef ( new Map < number , HTMLDivElement | null > ( ) ) ;
872+ const markerIds = useMemo (
873+ ( ) => ( engine . status === 'ready' ? ViewportController . markerIds ( ) : [ ] ) ,
874+ [ structRev , engine . status ] ,
875+ ) ;
868876 // Physics colliders aren't drawn by the renderer — outline each (box polygon /
869877 // circle) as a gizmo so you can see/tune collider shapes without entering Play.
870878 const colliderRefs = useRef ( new Map < number , SVGSVGElement | null > ( ) ) ;
@@ -882,29 +890,55 @@ export function Viewport() {
882890 const tileColModelRef = useRef < { key : string ; model : TilesetModel | null } > ( { key : '' , model : null } ) ;
883891 useEffect ( ( ) => {
884892 const clear = ( ) => { tileColPiecesRef . current = [ ] ; } ;
885- if ( engine . status !== 'ready' || ! showTileCollision || ! tilemapSelected || primaryId == null ) {
893+ if ( engine . status !== 'ready' || ! showTileCollision ) {
886894 clear ( ) ;
887895 tileColModelRef . current = { key : '' , model : null } ;
888896 return ;
889897 }
890- const refs = layerTilesetRefs ( primaryId ) ;
891- const key = refs . join ( '|' ) ;
892- const build = ( ) => {
893- const model = tileColModelRef . current . model ;
894- tileColPiecesRef . current = model ? ViewportController . tilemapColliderOutlines ( primaryId , model ) : [ ] ;
898+ // Collision (obstacle) layers ALWAYS contribute their outlines — the overlay IS their
899+ // content (they render nothing), so they stay visible unselected. They all share the
900+ // single built-in palette model (no per-layer disk load). A selected NON-collision
901+ // tilemap additionally shows its per-tile collision as a debug aid (selected-only).
902+ const collisionIds : number [ ] = [ ] ;
903+ for ( const id of SceneModel . entityOrder ( ) ) {
904+ const e = SceneModel . entityBySource ( id ) ;
905+ if ( e ?. components . some ( ( c ) => c . type === 'TilemapLayer' ) && isCollisionPaletteRef ( layerTilesetRefs ( id ) ) ) {
906+ collisionIds . push ( id ) ;
907+ }
908+ }
909+ const paletteModel = collisionIds . length > 0 ? buildCollisionPaletteModel ( ) : null ;
910+ const selId = tilemapSelected && primaryId != null && ! collisionIds . includes ( primaryId ) ? primaryId : null ;
911+
912+ const rebuild = ( ) => {
913+ const pieces : TileCollisionPiece [ ] = [ ] ;
914+ if ( paletteModel ) {
915+ for ( const id of collisionIds ) pieces . push ( ...ViewportController . tilemapColliderOutlines ( id , paletteModel ) ) ;
916+ }
917+ if ( selId != null && tileColModelRef . current . model ) {
918+ pieces . push ( ...ViewportController . tilemapColliderOutlines ( selId , tileColModelRef . current . model ) ) ;
919+ }
920+ tileColPiecesRef . current = pieces ;
895921 } ;
896- // Same tileset list as last time → reuse the cached model, just re-read the tiles.
897- if ( tileColModelRef . current . key === key && tileColModelRef . current . model ) { build ( ) ; return ; }
898- // Tileset refs changed (or first show): reload the model, then build once it lands.
922+
923+ // Collision layers render immediately (sync). A selected .estileset tilemap may need an
924+ // async model load; show the collision layers now and fold it in once its model lands.
925+ if ( selId == null ) {
926+ tileColModelRef . current = { key : '' , model : null } ;
927+ rebuild ( ) ;
928+ return ;
929+ }
930+ const refs = layerTilesetRefs ( selId ) ;
931+ const key = refs . join ( '|' ) ;
932+ if ( tileColModelRef . current . key === key && tileColModelRef . current . model ) { rebuild ( ) ; return ; }
899933 let alive = true ;
900- clear ( ) ;
934+ rebuild ( ) ; // collision layers appear at once; the selected layer joins on model load
901935 void loadLayerTilesetModel ( refs ) . then ( ( model ) => {
902936 if ( ! alive ) return ;
903937 tileColModelRef . current = { key, model } ;
904- build ( ) ;
938+ rebuild ( ) ;
905939 } ) ;
906940 return ( ) => { alive = false ; } ;
907- } , [ engine . status , showTileCollision , tilemapSelected , primaryId , dataRev ] ) ;
941+ } , [ engine . status , showTileCollision , tilemapSelected , primaryId , dataRev , structRev ] ) ;
908942
909943 // Scene-authored joints are equally invisible — draw each as an anchor link (plus
910944 // axis/velocity direction). Keyed by entity + joint type; same physics show flag.
@@ -1669,6 +1703,19 @@ export function Viewport() {
16691703 }
16701704 }
16711705
1706+ // Marker pins — position each at its entity's world point (or hide it when the
1707+ // marker is off-camera/removed). Same edit-mode + gizmos-on gate as the other icons.
1708+ for ( const [ mid , wrap ] of markerRefs . current ) {
1709+ if ( ! wrap ) continue ;
1710+ const mg = camsOn ? ViewportController . getMarkerGizmo ( mid ) : null ;
1711+ if ( mg ) {
1712+ wrap . style . visibility = 'visible' ;
1713+ wrap . style . transform = `translate(${ mg . cx } px, ${ mg . cy } px)` ;
1714+ } else {
1715+ wrap . style . visibility = 'hidden' ; // an invisible pin must not swallow clicks
1716+ }
1717+ }
1718+
16721719 // Particle-emitter gizmos — a clickable icon at the emitter + its spawn-shape
16731720 // outline (cone wedge / circle / box / point), so an otherwise-invisible emitter
16741721 // is placeable and aimable in edit mode. Same edit-mode + gizmos-on gate.
@@ -2218,6 +2265,34 @@ export function Viewport() {
22182265 ) ;
22192266 } ) }
22202267
2268+ { markerIds . map ( ( id ) => {
2269+ const src = SceneModel . sourceFor ( id ) ;
2270+ const name = src != null ? SceneModel . entityBySource ( src ) ?. name : undefined ;
2271+ return (
2272+ < div
2273+ key = { id }
2274+ ref = { ( el ) => {
2275+ if ( el ) markerRefs . current . set ( id , el ) ;
2276+ else markerRefs . current . delete ( id ) ;
2277+ } }
2278+ className = "viewport__marker-gizmo"
2279+ >
2280+ < span
2281+ className = "viewport__marker-hit"
2282+ role = "button"
2283+ title = { name }
2284+ onPointerDown = { ( e ) => {
2285+ if ( e . button !== 0 || src == null ) return ;
2286+ e . stopPropagation ( ) ;
2287+ useSelection . getState ( ) . select ( src ) ;
2288+ } }
2289+ >
2290+ < MapPin className = "viewport__marker-icon" size = { 16 } strokeWidth = { 2 } />
2291+ </ span >
2292+ </ div >
2293+ ) ;
2294+ } ) }
2295+
22212296 { /* Tile-collision overlay: ONE viewport-spanning SVG for the selected layer's whole
22222297 collision (solid outlines + dashed sensors + one-way arrows). The rAF writes the
22232298 combined path data each frame; empty when the flag's off or no tilemap is picked. */ }
0 commit comments