diff --git a/thermion_dart/hook/build.dart b/thermion_dart/hook/build.dart index 89ddf4b46..573866d7b 100644 --- a/thermion_dart/hook/build.dart +++ b/thermion_dart/hook/build.dart @@ -99,6 +99,14 @@ outputDirectory : ${outputDirectory.path} sources = sources.where((p) => !p.contains("linux")).toList(); } + // iOS is Metal-only — exclude Vulkan-utility sources whose symbols + // resolve through `bluevk` (which iOS does not link). Without this + // exclusion, linking fails with "Undefined symbols: bluevk::vk*". + // See native/src/vulkan/{VulkanUtils,BaseVulkanTexture}.cpp. + if (targetOS == OS.iOS) { + sources = sources.where((p) => !p.contains("vulkan")).toList(); + } + // Material source paths (used by _processMaterials below) final materialSources = { 'capture_uv': 'native/include/material/capture_uv.c', @@ -110,7 +118,11 @@ outputDirectory : ${outputDirectory.path} 'edge_outline': 'native/include/material/edge_outline.c', 'wireframe': 'native/include/material/wireframe.c', 'translation_axis': 'native/include/material/translation_axis.c', - 'gizmo': 'native/include/material/gizmo.c', + // Renamed from gizmo.c to avoid a case-insensitive .obj collision + // with scene/Gizmo.cpp on Windows (both produced gizmo.obj, the + // material write-clobbered the class .obj, and the linker reported + // four LNK2019s for thermion::Gizmo::{Gizmo,pick,highlight,unhighlight}). + 'gizmo': 'native/include/material/gizmo_material.c', 'bone_overlay': 'native/include/material/bone_overlay.c', }; @@ -252,7 +264,11 @@ outputDirectory : ${outputDirectory.path} "/std:c++20", if (buildMode == BuildMode.debug) ...["/MDd", "/Zi"], if (buildMode == BuildMode.release) "/MD", - "/VERBOSE", + // /VERBOSE is a linker option, not a compiler one — cl.exe parses it + // as the deprecated /V and emits warning D9035. If the + // verbose link map is ever needed for diagnostics, pass it after + // native_toolchain_c's own /link separator (see libraryDirectories + // / linkerOptions paths in run_cbuilder.dart). ...defines.keys.map((k) => "/D$k=${defines[k]}").toList(), ]); } @@ -418,10 +434,17 @@ outputDirectory : ${outputDirectory.path} if (platform == "windows") ...[ ...includeDirs.map((d) => "/I${path.join(pkgRootFilePath, d)}"), "@${srcs.uri.toFilePath(windows: true)}", - // ...sources, - // '/link', - // "/LIBPATH:$libDir", - // '/DLL', + // Library inputs (filament.lib, backend.lib, bluevk.lib, etc.) + // are declared via #pragma comment(lib, ...) directives in + // native/include/ThermionWin32.h, which is transitively included + // by the c_api headers and the Windows vulkan/d3d sources. The + // linker only needs to know WHERE to find those .lib files — + // that is wired via `libraryDirectories: [libDir]` below, which + // native_toolchain_c emits after its own /link separator + // (run_cbuilder.dart). Adding a second /link here puts cl.exe's + // auto-generated /LD and /Fe: AFTER our separator, where LINK + // ignores them as LNK4044 — the resulting binary has no /DLL + // and no entry point, failing with LNK1561. ], ], libraryDirectories: [libDir], diff --git a/thermion_dart/hook/link.dart b/thermion_dart/hook/link.dart index a191fb7c9..13ed63aab 100644 --- a/thermion_dart/hook/link.dart +++ b/thermion_dart/hook/link.dart @@ -12,6 +12,26 @@ void main(List args) async { var pkgRootFilePath = packageRoot.toFilePath(windows: Platform.isWindows); final logger = createLogger(pkgRootFilePath, "link.log"); + // The CLinker.library(... LinkerOptions.manual(...)) call below + // delegates to native_toolchain_c.runCl on Windows, which builds a + // cl.exe command line from the constructor's `sources` list. Our + // call passes no sources (the build hook already produced + // thermion_dart.dll), so cl.exe is invoked with only flags and exits + // immediately with `cl : Command line error D8003: missing source + // filename`. The link phase is optional here — pass the build + // hook's code assets through unchanged on Windows. Keep the + // CLinker call on platforms where it currently works. + if (input.config.code.targetOS == OS.windows) { + for (final asset in input.assets.code) { + output.assets.code.add(asset); + } + logger.info( + "Link step skipped on Windows; passed through " + "${input.assets.code.length} code asset(s).", + ); + return; + } + final clinker = CLinker.library( name: "thermion_dart", linkerOptions: LinkerOptions.manual(stripDebug: false)); diff --git a/thermion_dart/hook/log.dart b/thermion_dart/hook/log.dart index 25e24943c..e872bcb5a 100644 --- a/thermion_dart/hook/log.dart +++ b/thermion_dart/hook/log.dart @@ -13,9 +13,25 @@ Logger createLogger(String packageRoot, String logFilename) { final logger = Logger("") ..level = Level.ALL - ..onRecord.listen((record) => logFile.writeAsStringSync( - record.message + "\n", - mode: FileMode.append, - flush: true)); + ..onRecord.listen((record) { + logFile.writeAsStringSync( + record.message + "\n", + mode: FileMode.append, + flush: true); + // Tee SEVERE records to stderr so subprocess errors (cl.exe, + // clang, ld) actually surface to whoever's watching the + // build. `native_toolchain_c.runProcess` routes captured + // subprocess stderr through `logger.severe`, but on a + // failure it then throws a `ProcessException` whose message + // is just the command + exit code — the real compiler / + // linker output stays in the build.log file inside the pub + // cache where CI never sees it. Mirroring SEVERE to stderr + // makes the actual error visible without affecting + // successful-build noise (compilers don't emit much stderr + // on success). + if (record.level >= Level.SEVERE) { + stderr.writeln(record.message); + } + }); return logger; } \ No newline at end of file diff --git a/thermion_dart/native/include/material/gizmo.c b/thermion_dart/native/include/material/gizmo_material.c similarity index 100% rename from thermion_dart/native/include/material/gizmo.c rename to thermion_dart/native/include/material/gizmo_material.c diff --git a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart index 5e1c6343b..7e33fc8d0 100644 --- a/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart +++ b/thermion_flutter/thermion_flutter/lib/src/widgets/src/thermion_listener_widget.dart @@ -322,47 +322,132 @@ class _MobileListenerWidget extends StatefulWidget { } class _MobileListenerWidgetState extends State<_MobileListenerWidget> { + // Tap/double-tap detection state. We synthesize taps from the eager + // scale recognizer's start/update/end callbacks because the eager + // recognizer claims the gesture arena on PointerDown — separate + // TapGestureRecognizer / DoubleTapGestureRecognizer entries can no + // longer win. See `_EagerScaleGestureRecognizer` below. + Offset? _scaleStartFocal; + DateTime? _scaleStartTime; + double _scaleMaxMovement = 0; + Offset? _lastTapPosition; + DateTime? _lastTapTime; + + static const _kTapMaxMovement = 8.0; + static const _kTapMaxDuration = Duration(milliseconds: 250); + static const _kDoubleTapInterval = Duration(milliseconds: 300); + @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (details) { - widget.inputHandler.handle(TouchEvent(TouchEventType.tap, - details.localPosition.toVector2() * widget.pixelRatio, null)); - }, - onDoubleTap: () { - widget.inputHandler - .handle(TouchEvent(TouchEventType.doubleTap, null, null)); - }, - onScaleStart: (ScaleStartDetails event) async { - widget.inputHandler.handle(ScaleStartEvent( - numPointers: event.pointerCount, - localFocalPoint: ( - event.focalPoint.dx * widget.pixelRatio, - event.focalPoint.dy * widget.pixelRatio - ))); - }, - onScaleUpdate: (ScaleUpdateDetails event) async { - widget.inputHandler.handle(ScaleUpdateEvent( - numPointers: event.pointerCount, - localFocalPoint: ( - event.focalPoint.dx * widget.pixelRatio, - event.focalPoint.dy * widget.pixelRatio - ), - localFocalPointDelta: ( - event.focalPointDelta.dx * widget.pixelRatio, - event.focalPointDelta.dy * widget.pixelRatio - ), - rotation: event.rotation, - horizontalScale: event.horizontalScale, - verticalScale: event.verticalScale, - scale: event.scale, - )); - }, - onScaleEnd: (details) async { - widget.inputHandler - .handle(ScaleEndEvent(numPointers: details.pointerCount)); - }, - child: widget.child); + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + _EagerScaleGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_EagerScaleGestureRecognizer>( + () => _EagerScaleGestureRecognizer(), + (_EagerScaleGestureRecognizer instance) { + instance + ..onStart = (ScaleStartDetails event) { + _scaleStartFocal = event.focalPoint; + _scaleStartTime = DateTime.now(); + _scaleMaxMovement = 0; + widget.inputHandler.handle(ScaleStartEvent( + numPointers: event.pointerCount, + localFocalPoint: ( + event.focalPoint.dx * widget.pixelRatio, + event.focalPoint.dy * widget.pixelRatio + ))); + } + ..onUpdate = (ScaleUpdateDetails event) { + if (_scaleStartFocal != null) { + final movement = + (event.focalPoint - _scaleStartFocal!).distance; + if (movement > _scaleMaxMovement) { + _scaleMaxMovement = movement; + } + } + widget.inputHandler.handle(ScaleUpdateEvent( + numPointers: event.pointerCount, + localFocalPoint: ( + event.focalPoint.dx * widget.pixelRatio, + event.focalPoint.dy * widget.pixelRatio + ), + localFocalPointDelta: ( + event.focalPointDelta.dx * widget.pixelRatio, + event.focalPointDelta.dy * widget.pixelRatio + ), + rotation: event.rotation, + horizontalScale: event.horizontalScale, + verticalScale: event.verticalScale, + scale: event.scale, + )); + } + ..onEnd = (ScaleEndDetails event) { + final now = DateTime.now(); + final wasTap = _scaleStartTime != null && + _scaleStartFocal != null && + now.difference(_scaleStartTime!) < _kTapMaxDuration && + _scaleMaxMovement < _kTapMaxMovement && + event.pointerCount == 0; + if (wasTap) { + final tapPosition = _scaleStartFocal!; + final isDoubleTap = _lastTapPosition != null && + _lastTapTime != null && + now.difference(_lastTapTime!) < _kDoubleTapInterval && + (tapPosition - _lastTapPosition!).distance < + _kTapMaxMovement; + if (isDoubleTap) { + widget.inputHandler.handle( + TouchEvent(TouchEventType.doubleTap, null, null)); + _lastTapPosition = null; + _lastTapTime = null; + } else { + widget.inputHandler.handle(TouchEvent( + TouchEventType.tap, + tapPosition.toVector2() * widget.pixelRatio, + null)); + _lastTapPosition = tapPosition; + _lastTapTime = now; + } + } + widget.inputHandler + .handle(ScaleEndEvent(numPointers: event.pointerCount)); + _scaleStartFocal = null; + _scaleStartTime = null; + _scaleMaxMovement = 0; + }; + }, + ), + }, + child: widget.child, + ); + } +} + +/// Variant of [ScaleGestureRecognizer] that claims the gesture arena +/// the moment a pointer is added, instead of waiting for displacement +/// to cross [kPanSlop]. +/// +/// Why: the default recognizer waits to disambiguate a one-finger pan +/// from a two-finger pinch. While it waits, an ancestor `Scrollable`'s +/// `VerticalDragGestureRecognizer` reaches its acceptance threshold +/// first and wins the arena — the touch is interpreted as a page +/// scroll instead of a viewport gesture. With this eager subclass, +/// ancestors lose the arena on the first PointerDown inside the +/// Thermion view, so single-finger orbit and pinch-zoom both resolve +/// to the viewport regardless of what scrollable wraps it. +/// +/// Tradeoff: separate `TapGestureRecognizer` / +/// `DoubleTapGestureRecognizer` entries can no longer participate +/// (eager scale wins arena before they ever accept). The mobile +/// listener compensates by synthesizing tap and double-tap events +/// from the scale callbacks — a "tap" is a scale gesture whose total +/// focal-point movement stays below 8px and whose total duration +/// stays below 250 ms; "double-tap" is two such taps within 300 ms. +class _EagerScaleGestureRecognizer extends ScaleGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); } } diff --git a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt index 2ba2c0598..bea37109f 100644 --- a/thermion_flutter/thermion_flutter/windows/CMakeLists.txt +++ b/thermion_flutter/thermion_flutter/windows/CMakeLists.txt @@ -27,18 +27,50 @@ target_compile_features(${PLUGIN_NAME} PUBLIC cxx_std_20) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) set(GENERATED_HEADERS_CMAKE "${CMAKE_CURRENT_SOURCE_DIR}/../.dart_tool/generated_headers.cmake") -include("${GENERATED_HEADERS_CMAKE}") -target_include_directories(${PLUGIN_NAME} INTERFACE +# `generated_headers.cmake` is produced by `thermion_flutter`'s build hook +# (`hook/build.dart`) during `flutter assemble dart_build`, which runs as a +# custom build step inside MSBuild AFTER cmake config completes. On a fresh +# checkout the file does not yet exist, so an unconditional `include()` +# would error out (`include could not find requested file`) and abort cmake +# — preventing the dart_build step from ever running and leaving a fresh +# `flutter run` / `flutter build windows` permanently stuck unless the user +# knows to fire the hook out of band first (e.g. via `flutter test`). +# +# Include conditionally and register the file as a configure dependency so +# that, once dart_build produces it, the next build invocation +# auto-regenerates cmake and the plugin compile picks up the correct +# `DART_PKG_HEADERS`. On a clean checkout this means a two-pass build: +# first pass populates the file (plugin compile fails because +# DART_PKG_HEADERS is empty), second pass succeeds. +if(EXISTS "${GENERATED_HEADERS_CMAKE}") + include("${GENERATED_HEADERS_CMAKE}") +else() + message(STATUS + "thermion_flutter: ${GENERATED_HEADERS_CMAKE} not yet present. " + "Cmake will continue with an empty DART_PKG_HEADERS so that " + "`flutter assemble dart_build` can run and produce the file; " + "the next build will pick it up automatically.") + set(DART_PKG_HEADERS "") +endif() +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${GENERATED_HEADERS_CMAKE}") + +# PUBLIC scope: thermion_flutter_plugin.cpp itself needs the Filament +# headers (Platform.h transitively pulls in `utils::io::ostream`, etc.) +# AND any consumer that includes thermion_flutter_plugin.h needs them +# too. The previous INTERFACE-only scope hid the headers from this +# target's own compile, so `error C3083: 'io': the symbol to the left +# of a '::' must be a type` triggered on Platform.h:99. +# +# The second `include_directories(${PLUGIN_NAME} INTERFACE ...)` call +# below is also invalid CMake syntax — `include_directories` takes +# only paths, no target or scope keyword — so it silently does +# nothing. Removed. +target_include_directories(${PLUGIN_NAME} PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}" - "${DART_PKG_HEADERS}" -) - -include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include" - "${CMAKE_CURRENT_SOURCE_DIR}" - "${DART_PKG_HEADERS}" + ${DART_PKG_HEADERS} ) get_cmake_property(_variableNames VARIABLES) diff --git a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h index 46b9d70a1..82af6e082 100644 --- a/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h +++ b/thermion_flutter/thermion_flutter/windows/thermion_flutter_plugin.h @@ -14,6 +14,20 @@ #include "windows/import.h" +// Filament's `backend/Platform.h` (pulled in transitively by +// `WindowsVulkanContext.h` below) friend-declares +// `utils::io::ostream& operator<<(...)` without first declaring the +// nested namespace itself. Sibling Filament headers like +// `backend/DriverEnums.h` carry the forward declaration so the rest +// of Filament compiles cleanly, but this plugin's translation unit +// reaches `Platform.h` before any of them, leaving the namespace +// undeclared and cl.exe rejecting the friend with +// C3083 / C2039 / C4430. Mirror Filament's own forward declaration +// here so the friend resolves. +namespace utils::io { +class ostream; +} // namespace utils::io + #include "vulkan/windows/WindowsVulkanContext.h" namespace thermion::tflutter::windows {